feat(fs): synthesise per-user virtual home in working/ listings
ListDirectory now appends a synthetic <viewer-email>/ entry when the listed path is exactly <project>/working/ (depth 2, case-fold) and no real directory there matches the viewer's email under any case. The entry has IsDir=true and a new Virtual=true flag on listing.FileInfo (omitempty in JSON so existing clients that don't know the field continue to render it as a regular folder). A first write to that path materialises a real folder via the existing auto-own pipeline (EnsureCanonicalAncestors → WriteAutoOwnZddc), after which subsequent listings drop the synthetic entry naturally. Anonymous viewers, listings outside working/, and listings inside a deeper working/ subdirectory all skip the synthetic entry. Six tests cover: appears-when-missing, suppressed-when-real-exists (case-fold), anonymous-no-entry, staging/-no-entry, deep-working-no- entry, and pre-existing-PascalCase-Working/ still triggers it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55abce3448
commit
ce108e1eb3
3 changed files with 204 additions and 2 deletions
|
|
@ -103,5 +103,50 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user virtual home: when listing <project>/working/ for an
|
||||||
|
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
|
||||||
|
// no real folder of any case variant already exists for them. A
|
||||||
|
// first write to that path materialises a real folder with auto-own
|
||||||
|
// .zddc; subsequent listings drop the synthetic entry naturally.
|
||||||
|
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
|
||||||
|
result = append(result, syn)
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
||||||
|
// should be appended to a working/ listing, or (zero, false) when no
|
||||||
|
// synthetic entry applies.
|
||||||
|
//
|
||||||
|
// Conditions for the entry to fire:
|
||||||
|
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
|
||||||
|
// - viewerEmail is non-empty
|
||||||
|
// - real does not already contain a directory entry that case-folds
|
||||||
|
// to viewerEmail (so a materialised home doesn't get duplicated)
|
||||||
|
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
||||||
|
if viewerEmail == "" {
|
||||||
|
return listing.FileInfo{}, false
|
||||||
|
}
|
||||||
|
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") {
|
||||||
|
return listing.FileInfo{}, false
|
||||||
|
}
|
||||||
|
for _, fi := range real {
|
||||||
|
if !fi.IsDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// fi.Name carries a trailing slash for dirs.
|
||||||
|
bare := strings.TrimSuffix(fi.Name, "/")
|
||||||
|
if strings.EqualFold(bare, viewerEmail) {
|
||||||
|
return listing.FileInfo{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listing.FileInfo{
|
||||||
|
Name: viewerEmail + "/",
|
||||||
|
URL: baseURL + url.PathEscape(viewerEmail) + "/",
|
||||||
|
IsDir: true,
|
||||||
|
Virtual: true,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
|
||||||
148
zddc/internal/fs/tree_test.go
Normal file
148
zddc/internal/fs/tree_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTreeRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
// Permissive root .zddc so subdirectory ACL checks pass.
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||||
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var virtual *string
|
||||||
|
for i := range got {
|
||||||
|
if got[i].Virtual {
|
||||||
|
n := got[i].Name
|
||||||
|
virtual = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if virtual == nil {
|
||||||
|
t.Fatalf("expected synthetic <viewer-email>/ entry, got entries: %+v", got)
|
||||||
|
}
|
||||||
|
if *virtual != "alice@example.com/" {
|
||||||
|
t.Errorf("synthetic name = %q, want alice@example.com/", *virtual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
// A real folder exists for the viewer (any case).
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, fi := range got {
|
||||||
|
if fi.Virtual {
|
||||||
|
t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, fi := range got {
|
||||||
|
if fi.Virtual {
|
||||||
|
t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, fi := range got {
|
||||||
|
if fi.Virtual {
|
||||||
|
t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
// Listing inside working/ at depth 3+ — no synthetic entry should fire.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root,
|
||||||
|
"Proj/working/alice@example.com", "alice@example.com",
|
||||||
|
"/Proj/working/alice@example.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, fi := range got {
|
||||||
|
if fi.Virtual {
|
||||||
|
t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
||||||
|
root := setupTreeRoot(t)
|
||||||
|
// Pre-existing PascalCase Working/.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, fi := range got {
|
||||||
|
if fi.Virtual {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@ package listing
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// FileInfo matches Caddy's browse JSON output exactly.
|
// FileInfo matches Caddy's browse JSON output exactly (with one ZDDC-
|
||||||
// The archive browser (source.js) expects this exact shape.
|
// specific extension: Virtual). The archive browser (source.js) expects
|
||||||
|
// this exact shape.
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
Name string `json:"name"` // filename; directories have a trailing "/"
|
Name string `json:"name"` // filename; directories have a trailing "/"
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
|
@ -12,4 +13,12 @@ type FileInfo struct {
|
||||||
Mode uint32 `json:"mode"`
|
Mode uint32 `json:"mode"`
|
||||||
IsDir bool `json:"is_dir"`
|
IsDir bool `json:"is_dir"`
|
||||||
IsSymlink bool `json:"is_symlink"` // always false — no real symlinks served
|
IsSymlink bool `json:"is_symlink"` // always false — no real symlinks served
|
||||||
|
|
||||||
|
// Virtual marks an entry that doesn't exist on disk yet but is
|
||||||
|
// surfaced in listings as a synthetic affordance — e.g. the per-user
|
||||||
|
// <viewer-email>/ entry under working/. A first write to a virtual
|
||||||
|
// path materialises a real folder (with auto-own .zddc); subsequent
|
||||||
|
// listings drop the synthetic entry. Clients can use this flag to
|
||||||
|
// render the entry differently (placeholder badge, drop-target hint).
|
||||||
|
Virtual bool `json:"virtual,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue