From ce108e1eb32e7a875330e3639d094975ff69810b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 7 May 2026 09:20:25 -0500 Subject: [PATCH] feat(fs): synthesise per-user virtual home in working/ listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListDirectory now appends a synthetic / entry when the listed path is exactly /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) --- zddc/internal/fs/tree.go | 45 ++++++++++ zddc/internal/fs/tree_test.go | 148 +++++++++++++++++++++++++++++++++ zddc/internal/listing/types.go | 13 ++- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 zddc/internal/fs/tree_test.go diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 4cae494..e275606 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -103,5 +103,50 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, result = append(result, fi) } + // Per-user virtual home: when listing /working/ for an + // authenticated viewer, surface a synthetic / 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 } + +// virtualUserHomeEntry returns the synthetic / 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 /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 +} diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go new file mode 100644 index 0000000..854f197 --- /dev/null +++ b/zddc/internal/fs/tree_test.go @@ -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 / 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) + } +} diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 992434b..4627137 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -2,8 +2,9 @@ package listing import "time" -// FileInfo matches Caddy's browse JSON output exactly. -// The archive browser (source.js) expects this exact shape. +// FileInfo matches Caddy's browse JSON output exactly (with one ZDDC- +// specific extension: Virtual). The archive browser (source.js) expects +// this exact shape. type FileInfo struct { Name string `json:"name"` // filename; directories have a trailing "/" Size int64 `json:"size"` @@ -12,4 +13,12 @@ type FileInfo struct { Mode uint32 `json:"mode"` IsDir bool `json:"is_dir"` 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 + // / 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"` }