fix(zddc-server): canonical-folder fallback also at the dispatcher

The previous fix in fs.ListDirectory was insufficient — main.go's
dispatcher calls os.Stat(absPath) before reaching ServeDirectory,
and 404s on the missing path before the listing code ever runs.
Symptom: GET <project>/working/ on a fresh project still returned
"Not Found" despite the read-side fallback being committed.

Add the same fallback at the dispatcher level: when os.Stat returns
NotExist AND the URL ends with "/" AND the path matches
IsProjectRootFolder, fall through to ServeDirectory rather than
404. ServeDirectory's ACL check + ListDirectory's empty-listing
behavior take it from there.

Separately, fs.ListDirectory now initializes its result slice to
make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`,
so the JSON encoder emits "[]" rather than "null" for empty
listings — clients (browse, archive) expect an array and choke on
null.

New test TestDispatchEmptyCanonicalProjectFolders covers the four
canonical names (archive/working/staging/reviewing) on a project
where none of them exist on disk yet, plus the negative case (a
non-canonical missing path still 404s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 20:59:30 -05:00
parent 1d12cfe804
commit 41e6576111
3 changed files with 83 additions and 1 deletions

View file

@ -841,6 +841,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
} }
} }
// Empty-listing fallback for canonical project-root folders.
// <project>/{archive,working,staging,reviewing}/ that don't
// yet exist on disk should render as a usable empty
// directory view rather than 404, so the stage-strip nav
// links land on a real page on a fresh project. The
// matching read-side fallback in fs.ListDirectory returns
// 200 + [] for the same paths; we fall through to
// ServeDirectory which goes through that path and applies
// ACL via the parent project's .zddc cascade.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
strings.HasSuffix(urlPath, "/") &&
zddc.IsProjectRootFolder(strings.TrimPrefix(strings.TrimSuffix(urlPath, "/"), "/")) {
handler.ServeDirectory(cfg, w, r)
return
}
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
} else { } else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)

View file

@ -466,6 +466,71 @@ func TestDispatchSlashRouting(t *testing.T) {
} }
} }
// Canonical project-root folders (archive/working/staging/reviewing)
// that don't yet exist on disk must render as 200 + empty listing
// rather than 404, so the stage-strip nav lands on a usable view on a
// fresh project. Mirror of fs.ListDirectory's read-side fallback at
// the dispatcher level — without this, os.Stat 404s before
// ServeDirectory ever runs.
func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
// Project exists, but NO subdirs (no archive/, working/, staging/,
// reviewing/). Fresh-project state.
mustMkdir(t, filepath.Join(root, "Project"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// JSON request → 200 + JSON array (not null, not 404).
// Virtual user-home injection at <project>/working/ depends on a
// context-bound email; this test calls dispatch directly without
// the ACL middleware that sets that context, so email is "" here
// and working/ also returns []. virtualUserHomeEntry's email
// branch is covered separately by tests in internal/fs/tree_test.go.
for _, stage := range []string{"archive", "working", "staging", "reviewing"} {
t.Run("json/"+stage, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
if body != "[]" {
t.Errorf("%s/ body=%q, want %q", stage, body, "[]")
}
})
}
// Non-canonical missing folder still 404s (the fallback is
// scoped to the four canonical names, not a blanket "missing →
// empty" rule).
t.Run("non-canonical missing → 404", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
})
}
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on // TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file // any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates // API's write path, so a write to an archive URL never silently mutates

View file

@ -63,7 +63,9 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
} }
} }
var result []listing.FileInfo // Empty (not nil) so the JSON encoder emits [] rather than null
// when no entries match — clients (browse, archive) expect an array.
result := make([]listing.FileInfo, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()