diff --git a/AGENTS.md b/AGENTS.md index 4f07898..65ecf00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ path that fails loudly and visibly on the developer's terminal. - No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+) - The container image does NOT require Go on the host — the Containerfile uses a multi-stage build - Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories -- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision +- Every folder exposes a `.archive` virtual directory backed by the same global index — the depth in the URL only matters so HTML produced for offline use can reach `.archive/` via `../.archive/` relative links and have the browser resolve them before the request hits the server. The flat listing emits two redirect entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both redirect to the first chronologically received copy of the named revision. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. ACL is the only filter: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory; per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree - ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`) - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like install.zip's `_template/` that's still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. diff --git a/zddc/internal/archive/index.go b/zddc/internal/archive/index.go index 8ac7500..884e7f2 100644 --- a/zddc/internal/archive/index.go +++ b/zddc/internal/archive/index.go @@ -268,30 +268,55 @@ func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error { return indexTransmittalFolder(idx, fsRoot, transmittalDirPath, serverDir, date) } -// TrackingEntrySummary is a minimal summary for archive directory listings. -type TrackingEntrySummary struct { - TrackingNumber string - HighestPath string // server-relative path for the highest base revision +// Entry is one virtual redirect file in the archive listing. +// +// URLName is the filename surfaced under .archive/ (e.g. "123.html", +// "123_~A.html"). TargetPath is the server-relative path the redirect +// resolves to — used both as the redirect target and as the input to the +// per-entry ACL check. +type Entry struct { + URLName string + TargetPath string } -// AllTrackingEntries returns a snapshot of all tracking entries. -// Safe for concurrent use. -func (idx *Index) AllTrackingEntries() []TrackingEntrySummary { +// AllEntries returns a sorted snapshot of every redirect entry. Two kinds: +// +// - .html → first-chronological copy of the highest base rev +// - _.html → first-chronological copy of that specific base rev +// +// Modifier files (e.g. _+C1.html) remain reachable via the +// resolver but are not surfaced in the listing — they're return traffic +// (comments / markups), not items the user browses to as primary documents. +// +// Sort order is by URLName; the "." in .html sorts before the "_" +// in _.html, so each tracking number's highest-rev shortcut +// comes first, followed by its individual revisions in revision order. +func (idx *Index) AllEntries() []Entry { idx.mu.RLock() defer idx.mu.RUnlock() - result := make([]TrackingEntrySummary, 0, len(idx.ByTracking)) + var result []Entry for tn, te := range idx.ByTracking { - var highPath string if te.HighestBaseRev != "" { - if re, ok := te.ByRevision[te.HighestBaseRev]; ok { - highPath = re.BasePath + if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" { + result = append(result, Entry{ + URLName: tn + ".html", + TargetPath: re.BasePath, + }) } } - result = append(result, TrackingEntrySummary{ - TrackingNumber: tn, - HighestPath: highPath, - }) + for rev, re := range te.ByRevision { + if re.BasePath == "" { + continue + } + result = append(result, Entry{ + URLName: tn + "_" + rev + ".html", + TargetPath: re.BasePath, + }) + } } + sort.Slice(result, func(i, j int) bool { + return result[i].URLName < result[j].URLName + }) return result } diff --git a/zddc/internal/archive/index_test.go b/zddc/internal/archive/index_test.go new file mode 100644 index 0000000..855a642 --- /dev/null +++ b/zddc/internal/archive/index_test.go @@ -0,0 +1,195 @@ +package archive + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +func mkTransmittal(t *testing.T, fsRoot, folderName string, files ...string) { + t.Helper() + dir := filepath.Join(fsRoot, folderName) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + for _, f := range files { + path := filepath.Join(dir, f) + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } +} + +func TestCompareRevisions_DraftOrdering(t *testing.T) { + cases := []struct { + a, b string + want int // sign only + }{ + {"~A", "A", -1}, + {"~A", "~B", -1}, + {"A", "B", -1}, + {"~A", "~A", 0}, + {"A", "~A", 1}, + } + for _, c := range cases { + got := compareRevisions(c.a, c.b) + var sign int + if got < 0 { + sign = -1 + } else if got > 0 { + sign = 1 + } + if sign != c.want { + t.Errorf("compareRevisions(%q, %q) sign = %d, want %d", c.a, c.b, sign, c.want) + } + } +} + +func TestIndexAndResolve_DraftOnly(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + "123_~A (IFR) - Title.pdf", + ) + + idx, err := BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + + te, ok := idx.ByTracking["123"] + if !ok { + t.Fatalf("tracking 123 not indexed") + } + if te.HighestBaseRev != "~A" { + t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev) + } + + if _, ok := Resolve(idx, "123.html"); !ok { + t.Errorf("Resolve(123.html) failed") + } + if _, ok := Resolve(idx, "123_~A.html"); !ok { + t.Errorf("Resolve(123_~A.html) failed") + } +} + +func TestIndexAndResolve_DraftWithModifier(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + "123_~A (IFR) - Title.pdf", + ) + mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments", + "123_~A+C1 (RTN) - Comments.pdf", + ) + + idx, _ := BuildIndex(root) + if _, ok := Resolve(idx, "123_~A+C1.html"); !ok { + t.Errorf("Resolve(123_~A+C1.html) failed") + } +} + +// "First chronologically found version of the latest rev": when the same rev +// appears in two transmittals, the earlier date's copy wins. +func TestRecordFile_FirstChronologicalWins(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "2025-03-01_Late (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + mkTransmittal(t, root, "2025-01-01_Early (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + + idx, _ := BuildIndex(root) + target, ok := Resolve(idx, "123_A.html") + if !ok { + t.Fatalf("Resolve(123_A.html) failed") + } + if !contains(target, "2025-01-01_Early") { + t.Errorf("got %q, want path under 2025-01-01_Early/", target) + } +} + +// AllEntries: every (tracking) gets .html (highest) AND a +// _.html for every base revision present. +func TestAllEntries_PerRevisionSurfaced(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + "123_~A (IFR) - Title.pdf", + ) + mkTransmittal(t, root, "2025-03-01_T3 (IFC) - Title", + "123_A (IFC) - Title.pdf", + "456_0 (IFR) - Other.pdf", + ) + + idx, _ := BuildIndex(root) + entries := idx.AllEntries() + + got := make(map[string]string, len(entries)) + for _, e := range entries { + got[e.URLName] = e.TargetPath + } + + // Highest-rev shortcut + each per-rev redirect should be present. + wantNames := []string{ + "123.html", // highest of 123 → A + "123_A.html", // explicit A + "123_~A.html", // explicit draft + "456.html", // highest of 456 → 0 + "456_0.html", // explicit 0 + } + for _, n := range wantNames { + if _, ok := got[n]; !ok { + t.Errorf("missing entry %q; got %v", n, sortedKeys(got)) + } + } + + // 123.html should resolve to the same path as 123_A.html (both point to + // the highest-rev's first-chronological copy). + if got["123.html"] != got["123_A.html"] { + t.Errorf("123.html (%q) != 123_A.html (%q); should both resolve to highest", + got["123.html"], got["123_A.html"]) + } + + // Sort: .html sorts before _*.html (because '.'<'_'). + for i := 1; i < len(entries); i++ { + if entries[i-1].URLName > entries[i].URLName { + t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName) + } + } +} + +// Modifier-only files (no base) don't get a .html or +// _.html entry — the redirect would have nowhere to go since +// re.BasePath is empty. They remain reachable via _+.html +// through the resolver but are not surfaced in the listing. +func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments", + "123_~A+C1 (RTN) - Comments.pdf", + ) + + idx, _ := BuildIndex(root) + for _, e := range idx.AllEntries() { + if e.URLName == "123.html" || e.URLName == "123_~A.html" { + t.Errorf("unexpected entry %q (no base file exists)", e.URLName) + } + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 7e33054..7c107b3 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -15,12 +15,25 @@ import ( // ServeArchive handles requests under a .archive virtual path segment. // -// contextPath: the URL path leading up to (but not including) .archive (e.g. "/Project-123") +// .archive is exposed at every folder depth so HTML produced for offline use +// can reference sibling tracking numbers via "../.archive/.html". +// In a browser the relative link is resolved before the request reaches the +// server, so the server treats every .archive request the same regardless of +// the contextPath it arrived under: the same global index is consulted, and +// access is gated only by the cascading .zddc ACL. +// +// contextPath: the URL path leading up to (but not including) .archive +// - used to gate the listing endpoint (caller must have ACL access to the +// directory the .archive virtual entry sits in — otherwise just knowing +// the folder exists would leak) +// - used as the URL prefix for the entries returned in the listing +// // filename: the part after .archive/ (empty for directory listing) func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) { email := EmailFromContext(r) - // ACL check on the context directory + // ACL gate on the context directory: callers who can't reach the + // directory hosting this .archive shouldn't be able to query it either. dirPath := strings.TrimPrefix(contextPath, "/") dirPath = strings.TrimSuffix(dirPath, "/") absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath)) @@ -34,19 +47,19 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, } if filename == "" { - // Directory listing: return all trackingNumber.html entries this user can access serveArchiveListing(cfg, idx, w, r, contextPath, email) return } - // Single file resolve target, ok := archive.Resolve(idx, filename) if !ok { http.Error(w, "Not Found", http.StatusNotFound) return } - // ACL check on the resolved file's directory (prevents info leakage) + // Per-target ACL: the resolved file may live in a subtree the caller + // can't reach even though they could reach the contextPath. 404 (not + // 403) so the tracking number's mere existence isn't disclosed. fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target))) chain, err = zddc.EffectivePolicy(cfg.Root, fileDir) if err != nil { @@ -57,29 +70,45 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, return } - // 302 redirect to the real file path http.Redirect(w, r, "/"+target, http.StatusFound) } func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) { - allEntries := idx.AllTrackingEntries() - archiveBase := contextPath + "/" + cfg.IndexPath + "/" + allEntries := idx.AllEntries() + archiveBase := contextPath + if !strings.HasSuffix(archiveBase, "/") { + archiveBase += "/" + } + archiveBase += cfg.IndexPath + "/" + + // ACL chains are folder-keyed and the listing typically hits the same + // few directories repeatedly (one per transmittal folder), so cache the + // allow/deny decision per directory rather than re-walking .zddc files + // for every entry. + aclCache := make(map[string]bool) + allowed := func(targetPath string) bool { + fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath))) + if v, ok := aclCache[fileDir]; ok { + return v + } + chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) + if err != nil { + aclCache[fileDir] = false + return false + } + v := zddc.AllowedWithChain(chain, email) + aclCache[fileDir] = v + return v + } var result []listing.FileInfo - for _, item := range allEntries { - if item.HighestPath == "" { + for _, e := range allEntries { + if !allowed(e.TargetPath) { continue } - // ACL check on the resolved file's directory - fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(item.HighestPath))) - chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) - if err != nil || !zddc.AllowedWithChain(chain, email) { - continue - } - entryName := item.TrackingNumber + ".html" result = append(result, listing.FileInfo{ - Name: entryName, - URL: archiveBase + entryName, + Name: e.URLName, + URL: archiveBase + e.URLName, IsDir: false, }) } diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go new file mode 100644 index 0000000..325d70d --- /dev/null +++ b/zddc/internal/handler/archivehandler_test.go @@ -0,0 +1,375 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/archive" + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// archiveTestRoot lays down a two-project tree so listings exercise scope and +// ACL cascading. ACLs are written per-test in the helper that calls this. +// +// / +// ProjectA/ +// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf +// 2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf +// 2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf +// ProjectB/ +// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf +func archiveTestRoot(t *testing.T) (string, *archive.Index) { + t.Helper() + root := t.TempDir() + + mk := func(rel string) { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf") + mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf") + mk("ProjectA/2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf") + mk("ProjectB/2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf") + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + return root, idx +} + +// writeZddc writes a .zddc YAML at //.zddc and clears the +// per-directory policy cache so a previous test's permissive .zddc doesn't +// bleed into this one. +func writeZddc(t *testing.T, root, rel, body string) { + t.Helper() + dir := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + zddc.InvalidateCache(dir) +} + +func archiveCfg(root string) config.Config { + return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"} +} + +func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder { + t.Helper() + urlPath := contextPath + if !strings.HasSuffix(urlPath, "/") { + urlPath += "/" + } + urlPath += ".archive/" + filename + req := httptest.NewRequest(http.MethodGet, urlPath, nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) + rec := httptest.NewRecorder() + ServeArchive(cfg, idx, rec, req, contextPath, filename) + return rec +} + +func decodeListing(t *testing.T, body []byte) []listing.FileInfo { + t.Helper() + var out []listing.FileInfo + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, body) + } + return out +} + +func names(entries []listing.FileInfo) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Name) + } + return out +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} + +// .archive at any depth serves the SAME global index (modulo ACL). Only the +// URL prefix on the entries differs, so relative ../.archive/ links resolve +// to a working server endpoint no matter which folder the source page sits +// in. +func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + const email = "alice@example.com" + + cases := []struct { + name string + contextPath string + urlPrefix string + }{ + {"root", "/", "/.archive/"}, + {"project depth", "/ProjectA", "/ProjectA/.archive/"}, + {"unrelated project depth", "/ProjectB", "/ProjectB/.archive/"}, + } + + wantNames := []string{"100.html", "100_A.html", "100_~A.html", "200.html", "200_0.html"} + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := callArchive(t, cfg, idx, email, c.contextPath, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + got := decodeListing(t, rec.Body.Bytes()) + gotNames := names(got) + for _, want := range wantNames { + if !contains(gotNames, want) { + t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames) + } + } + for _, e := range got { + if !strings.HasPrefix(e.URL, c.urlPrefix) { + t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix) + } + } + }) + } +} + +// Listing endpoint is gated by the contextPath ACL: callers who can't reach +// the directory the .archive virtually sits in get 403 (the directory is +// known to exist; just not accessible). +func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["alice@example.com"] +`) + writeZddc(t, root, "ProjectA", `acl: + deny: ["mallory@example.com"] + allow: ["alice@example.com"] +`) + cfg := archiveCfg(root) + + rec := callArchive(t, cfg, idx, "mallory@example.com", "/ProjectA", "") + if rec.Code != http.StatusForbidden { + t.Errorf("denied caller got status %d, want 403; body = %s", rec.Code, rec.Body.String()) + } + + rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") + if rec.Code != http.StatusOK { + t.Errorf("allowed caller got status %d, want 200; body = %s", rec.Code, rec.Body.String()) + } +} + +// Listing entries are filtered per-target by ACL: a caller denied at a +// subtree sees no entries from it — even when querying /.archive/ at the +// root where they ARE allowed. Excluding a user from a subdir requires an +// explicit deny there (the cascade is "first explicit match wins, bottom- +// up", so a child allow list doesn't narrow a parent's allow:["*"]). +func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + writeZddc(t, root, "ProjectB", `acl: + deny: ["alice@example.com"] +`) + cfg := archiveCfg(root) + + rec := callArchive(t, cfg, idx, "alice@example.com", "/", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + gotNames := names(decodeListing(t, rec.Body.Bytes())) + + for _, want := range []string{"100.html", "100_A.html", "100_~A.html"} { + if !contains(gotNames, want) { + t.Errorf("alice missing accessible entry %q; got %v", want, gotNames) + } + } + for _, hidden := range []string{"200.html", "200_0.html"} { + if contains(gotNames, hidden) { + t.Errorf("alice should not see ACL-blocked entry %q; got %v", hidden, gotNames) + } + } + + rec = callArchive(t, cfg, idx, "bob@example.com", "/", "") + gotNames = names(decodeListing(t, rec.Body.Bytes())) + if !contains(gotNames, "200.html") { + t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames) + } +} + +// Direct redirect requests for a tracking number whose target the caller +// can't read return 404 (not 403, not 302) — the file's existence must not +// leak across the ACL boundary. +func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + writeZddc(t, root, "ProjectB", `acl: + deny: ["alice@example.com"] +`) + cfg := archiveCfg(root) + + rec := callArchive(t, cfg, idx, "alice@example.com", "/", "200.html") + if rec.Code != http.StatusNotFound { + t.Errorf("alice → 200.html: status %d, want 404 (target ACL-denied)", rec.Code) + } + + for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} { + rec := callArchive(t, cfg, idx, "alice@example.com", "/", fn) + if rec.Code != http.StatusFound { + t.Errorf("alice → %s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String()) + } + } + + rec = callArchive(t, cfg, idx, "bob@example.com", "/", "200.html") + if rec.Code != http.StatusFound { + t.Errorf("bob → 200.html: status %d, want 302", rec.Code) + } +} + +// Cascade direction sanity check: a denial at the subtree wins over an +// allow at the parent, AND a target-level allow can rescue a user the +// parent didn't mention. Both directions of cascade must be exercised so +// future refactors of the per-target ACL helper can't silently break one. +func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { + root, idx := archiveTestRoot(t) + // Root: deny default — only bob is on the list. ProjectA: explicitly + // allow alice. So alice is rescued at the leaf, mallory stays out + // everywhere, bob stays in everywhere. + writeZddc(t, root, ".", `acl: + allow: ["bob@example.com"] +`) + writeZddc(t, root, "ProjectA", `acl: + allow: ["alice@example.com"] +`) + cfg := archiveCfg(root) + + cases := []struct { + email string + filename string + wantStatus int + why string + }{ + {"bob@example.com", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"}, + {"bob@example.com", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"}, + {"alice@example.com", "100.html", http.StatusFound, "alice rescued by ProjectA allow"}, + {"alice@example.com", "200.html", http.StatusNotFound, "alice not in ProjectB chain → 404"}, + // mallory is denied EVERYWHERE — including the /ProjectA contextPath + // — so she never reaches per-target evaluation; the contextPath + // gate returns 403. (404 leak-prevention only kicks in once the + // contextPath itself is reachable.) + {"mallory@example.com", "100.html", http.StatusForbidden, "mallory blocked at contextPath"}, + } + for _, c := range cases { + t.Run(c.email+"_"+c.filename, func(t *testing.T) { + // Use ProjectA as contextPath: alice is rescued there (so she + // passes the gate and we get to per-target ACL on the ProjectB + // resolve), and bob+mallory's behavior is governed by the root + // rules. + rec := callArchive(t, cfg, idx, c.email, "/ProjectA", c.filename) + if rec.Code != c.wantStatus { + t.Errorf("%s → %s: status %d, want %d (%s)", c.email, c.filename, rec.Code, c.wantStatus, c.why) + } + }) + } +} + +// Resolved redirect Location header must be the absolute path to the actual +// file under cfg.Root, regardless of which contextPath the caller used to +// reach .archive. So /ProjectA/.archive/100.html and /.archive/100.html +// both 302 to the same file. +func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + + wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A" + for _, ctx := range []string{"/", "/ProjectA", "/ProjectB"} { + rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") + if rec.Code != http.StatusFound { + t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String()) + continue + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, wantLocPrefix) { + t.Errorf("ctx=%s Location=%q, want prefix %q", ctx, loc, wantLocPrefix) + } + } +} + +// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched +// caller is denied. Verify this applies to listing entries too — a target +// in a directory with a restrictive .zddc is not surfaced to outsiders even +// though the file exists. +func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) { + root, idx := archiveTestRoot(t) + // Root .zddc allows alice only. No "*" — so anyone else is default-denied. + writeZddc(t, root, ".", `acl: + allow: ["alice@example.com"] +`) + cfg := archiveCfg(root) + + // alice sees everything she's allowed to. + rec := callArchive(t, cfg, idx, "alice@example.com", "/", "") + if rec.Code != http.StatusOK { + t.Fatalf("alice listing: status %d, want 200", rec.Code) + } + if len(decodeListing(t, rec.Body.Bytes())) == 0 { + t.Errorf("alice listing was empty, want entries") + } + + // Charlie isn't on any list → default-deny at root → 403 even for the listing. + rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "") + if rec.Code != http.StatusForbidden { + t.Errorf("charlie listing: status %d, want 403", rec.Code) + } + + // Direct resolve also denied (404 to avoid leak). + rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "100.html") + // contextPath ACL fires first: at root, charlie is denied → 403. + if rec.Code != http.StatusForbidden { + t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code) + } +} + +// Empty email never matches — even an `allow: ["*"]` policy denies it, +// which is the existing zddc package contract. .archive must honor it. +func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*@example.com"] +`) + cfg := archiveCfg(root) + + rec := callArchive(t, cfg, idx, "", "/", "") + if rec.Code != http.StatusForbidden { + t.Errorf("anonymous listing: status %d, want 403", rec.Code) + } +}