fix(zddc-server): per-revision .archive entries + global index with ACL filter
The .archive virtual directory now emits both <tracking>.html (highest base rev) and <tracking>_<rev>.html (each specific base rev) so HTML documents can deep-link to a known revision and have it resolve to the first chronologically received copy. Modifier files (<rev>+C1 etc.) stay reachable via the resolver but aren't surfaced in the listing. .archive at any folder depth serves the same global index — the depth exists so offline HTML can use ../.archive/<tracking>.html and let the browser resolve it before the request reaches the server. The earlier attempt at scoping listings to the contextPath subtree was wrong; gating is purely by ACL: contextPath gates the listing endpoint, and each entry's resolved file gets its own per-target ACL check (404 on denial, not 403, so cross-subtree existence isn't disclosed). Adds the first tests for the previously untested archive package, plus end-to-end ACL coverage for the handler (cascade direction, default-deny once any .zddc exists, anonymous denied under allow:[\"*@…\"], stable Location across contextPaths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
714faf60f2
commit
f56eb7d0f9
5 changed files with 659 additions and 35 deletions
|
|
@ -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+)
|
- 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
|
- 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
|
- 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: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy of the named revision. Modifier files (`<tracking>_<rev>+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`)
|
- 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".
|
- `.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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -268,30 +268,55 @@ func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
|
||||||
return indexTransmittalFolder(idx, fsRoot, transmittalDirPath, serverDir, date)
|
return indexTransmittalFolder(idx, fsRoot, transmittalDirPath, serverDir, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackingEntrySummary is a minimal summary for archive directory listings.
|
// Entry is one virtual redirect file in the archive listing.
|
||||||
type TrackingEntrySummary struct {
|
//
|
||||||
TrackingNumber string
|
// URLName is the filename surfaced under .archive/ (e.g. "123.html",
|
||||||
HighestPath string // server-relative path for the highest base revision
|
// "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.
|
// AllEntries returns a sorted snapshot of every redirect entry. Two kinds:
|
||||||
// Safe for concurrent use.
|
//
|
||||||
func (idx *Index) AllTrackingEntries() []TrackingEntrySummary {
|
// - <tracking>.html → first-chronological copy of the highest base rev
|
||||||
|
// - <tracking>_<rev>.html → first-chronological copy of that specific base rev
|
||||||
|
//
|
||||||
|
// Modifier files (e.g. <tracking>_<rev>+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 <tracking>.html sorts before the "_"
|
||||||
|
// in <tracking>_<rev>.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()
|
idx.mu.RLock()
|
||||||
defer idx.mu.RUnlock()
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]TrackingEntrySummary, 0, len(idx.ByTracking))
|
var result []Entry
|
||||||
for tn, te := range idx.ByTracking {
|
for tn, te := range idx.ByTracking {
|
||||||
var highPath string
|
|
||||||
if te.HighestBaseRev != "" {
|
if te.HighestBaseRev != "" {
|
||||||
if re, ok := te.ByRevision[te.HighestBaseRev]; ok {
|
if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" {
|
||||||
highPath = re.BasePath
|
result = append(result, Entry{
|
||||||
|
URLName: tn + ".html",
|
||||||
|
TargetPath: re.BasePath,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, TrackingEntrySummary{
|
for rev, re := range te.ByRevision {
|
||||||
TrackingNumber: tn,
|
if re.BasePath == "" {
|
||||||
HighestPath: highPath,
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
zddc/internal/archive/index_test.go
Normal file
195
zddc/internal/archive/index_test.go
Normal file
|
|
@ -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 <tracking>.html (highest) AND a
|
||||||
|
// <tracking>_<rev>.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: <tracking>.html sorts before <tracking>_*.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 <tracking>.html or
|
||||||
|
// <tracking>_<rev>.html entry — the redirect would have nowhere to go since
|
||||||
|
// re.BasePath is empty. They remain reachable via <tracking>_<rev>+<mod>.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
|
||||||
|
}
|
||||||
|
|
@ -15,12 +15,25 @@ import (
|
||||||
|
|
||||||
// ServeArchive handles requests under a .archive virtual path segment.
|
// 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/<tracking>.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)
|
// 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) {
|
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
||||||
email := EmailFromContext(r)
|
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.TrimPrefix(contextPath, "/")
|
||||||
dirPath = strings.TrimSuffix(dirPath, "/")
|
dirPath = strings.TrimSuffix(dirPath, "/")
|
||||||
absDir := filepath.Join(cfg.Root, filepath.FromSlash(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 == "" {
|
if filename == "" {
|
||||||
// Directory listing: return all trackingNumber.html entries this user can access
|
|
||||||
serveArchiveListing(cfg, idx, w, r, contextPath, email)
|
serveArchiveListing(cfg, idx, w, r, contextPath, email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single file resolve
|
|
||||||
target, ok := archive.Resolve(idx, filename)
|
target, ok := archive.Resolve(idx, filename)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
return
|
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)))
|
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
|
||||||
chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
|
chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -57,29 +70,45 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 302 redirect to the real file path
|
|
||||||
http.Redirect(w, r, "/"+target, http.StatusFound)
|
http.Redirect(w, r, "/"+target, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
|
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
|
||||||
allEntries := idx.AllTrackingEntries()
|
allEntries := idx.AllEntries()
|
||||||
archiveBase := contextPath + "/" + cfg.IndexPath + "/"
|
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
|
var result []listing.FileInfo
|
||||||
for _, item := range allEntries {
|
for _, e := range allEntries {
|
||||||
if item.HighestPath == "" {
|
if !allowed(e.TargetPath) {
|
||||||
continue
|
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{
|
result = append(result, listing.FileInfo{
|
||||||
Name: entryName,
|
Name: e.URLName,
|
||||||
URL: archiveBase + entryName,
|
URL: archiveBase + e.URLName,
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
375
zddc/internal/handler/archivehandler_test.go
Normal file
375
zddc/internal/handler/archivehandler_test.go
Normal file
|
|
@ -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.
|
||||||
|
//
|
||||||
|
// <root>/
|
||||||
|
// 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 <root>/<rel>/.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue