diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index 17c4681..7267a77 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -3,66 +3,55 @@ package apps import ( "path/filepath" "strings" - - "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" -) - -// Folder name conventions that gate which tools are virtually available -// at a given path. The names are case-sensitive; ZDDC convention uses -// the capitalized forms. The full canonical list lives in -// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls -// the relevant subsets from there to avoid duplication. -var ( - // Subset of zddc.AutoOwnFolderNames where classifier is virtually - // available (the same three folders that grant mkdir auto-ownership). - folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames - folderNamesWorking = []string{"Working"} - folderNamesStaging = []string{"Staging"} ) // AppAvailableAt reports whether app's virtual HTML can be served at -// requestDir. Rules: +// requestDir. Rules (case-insensitive on canonical folder names): // -// - archive: every directory (multi-project, project, archive, vendor) +// - archive: every directory (multi-project, project, archive, party) // - browse: every directory (generic file listing — also the default // served at folder URLs without an index.html; see directory.go) // - classifier: requestDir is, or descends from, a folder named -// "Incoming", "Working", or "Staging" (the directories where -// incoming/outgoing files get classified) -// - mdedit: requestDir is, or descends from, a "Working" folder -// (where markdown drafts are written and edited) -// - transmittal: requestDir is, or descends from, a "Staging" folder +// "working", "staging", or "incoming" (the directories where +// in-flight files get classified) +// - mdedit: requestDir is, or descends from, a "working" folder +// (where markdown drafts are written and edited, including review +// responses drafted in working//) +// - transmittal: requestDir is, or descends from, a "staging" folder // (where outgoing transmittals are prepared) // - landing: only at the deployment root (the project picker) // -// Operators can always drop a real .html file at any path to override -// — that path is served by the static handler regardless of this function's -// result. AppAvailableAt is consulted only when no real file exists. +// Operators can always drop a real .html file at any path to +// override — that path is served by the static handler regardless of +// this function's result. AppAvailableAt is consulted only when no +// real file exists. +// +// In the canonical layout, "incoming" only appears at +// archive//incoming/, so checking "any ancestor named incoming" +// is equivalent to checking "under a per-party incoming folder." func AppAvailableAt(root, requestDir, app string) bool { root = filepath.Clean(root) requestDir = filepath.Clean(requestDir) switch app { - case "archive": - return true - case "browse": + case "archive", "browse": return true case "landing": return requestDir == root case "classifier": - return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging) + return inAncestorWithName(root, requestDir, "working", "staging", "incoming") case "mdedit": - return inAncestorWithName(root, requestDir, folderNamesWorking) + return inAncestorWithName(root, requestDir, "working") case "transmittal": - return inAncestorWithName(root, requestDir, folderNamesStaging) + return inAncestorWithName(root, requestDir, "staging") } return false } // inAncestorWithName reports whether requestDir is, or has an ancestor -// (not including root itself), named one of names. The match is on the -// last segment of each directory in the chain root → requestDir. -func inAncestorWithName(root, requestDir string, names []string) bool { +// (not including root itself), whose last segment case-folds to one +// of names. Match is on segment names, case-insensitively. +func inAncestorWithName(root, requestDir string, names ...string) bool { if requestDir == root { return false } @@ -72,7 +61,7 @@ func inAncestorWithName(root, requestDir string, names []string) bool { } for _, part := range strings.Split(rel, string(filepath.Separator)) { for _, n := range names { - if part == n { + if strings.EqualFold(part, n) { return true } } diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 68e117d..5de4580 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -14,38 +14,46 @@ func TestAppAvailableAt(t *testing.T) { // archive: everywhere {root, "archive", true}, {root + "/Project-A", "archive", true}, - {root + "/Project-A/Working", "archive", true}, - {root + "/Project-A/Outgoing", "archive", true}, + {root + "/Project-A/working", "archive", true}, + {root + "/Project-A/some-other-folder", "archive", true}, // landing: only at root {root, "landing", true}, {root + "/Project-A", "landing", false}, - // classifier: Incoming/Working/Staging and subtrees + // classifier: working/, staging/, archive//incoming/ and subtrees {root, "classifier", false}, {root + "/Project-A", "classifier", false}, - {root + "/Project-A/Incoming", "classifier", true}, - {root + "/Project-A/Incoming/SubDir", "classifier", true}, - {root + "/Project-A/Working", "classifier", true}, - {root + "/Project-A/Staging", "classifier", true}, - {root + "/Project-A/Outgoing", "classifier", false}, - {root + "/Project-A/Working/deep/nested/path", "classifier", true}, + {root + "/Project-A/working", "classifier", true}, + {root + "/Project-A/working/deep/nested/path", "classifier", true}, + {root + "/Project-A/staging", "classifier", true}, + {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true}, + {root + "/Project-A/archive/ACME/incoming", "classifier", true}, + {root + "/Project-A/archive/ACME/incoming/sub", "classifier", true}, + {root + "/Project-A/archive/ACME/received", "classifier", false}, + {root + "/Project-A/archive/ACME/issued", "classifier", false}, + {root + "/Project-A/archive/ACME/mdl", "classifier", false}, + {root + "/Project-A/some-other-folder", "classifier", false}, - // mdedit: Working only + // mdedit: working/ only (review responses live in working//) + {root + "/Project-A/working", "mdedit", true}, + {root + "/Project-A/working/sub", "mdedit", true}, + {root + "/Project-A/staging", "mdedit", false}, + {root + "/Project-A/archive/ACME/incoming", "mdedit", false}, + + // transmittal: staging/ only + {root + "/Project-A/staging", "transmittal", true}, + {root + "/Project-A/staging/sub", "transmittal", true}, + {root + "/Project-A/working", "transmittal", false}, + {root + "/Project-A/archive/ACME/issued", "transmittal", false}, + + // case-fold: any case of canonical names matches {root + "/Project-A/Working", "mdedit", true}, - {root + "/Project-A/Working/SubDir", "mdedit", true}, - {root + "/Project-A/Incoming", "mdedit", false}, - {root + "/Project-A/Staging", "mdedit", false}, - - // transmittal: Staging only + {root + "/Project-A/WORKING", "mdedit", true}, {root + "/Project-A/Staging", "transmittal", true}, - {root + "/Project-A/Staging/SubDir", "transmittal", true}, - {root + "/Project-A/Incoming", "transmittal", false}, - {root + "/Project-A/Working", "transmittal", false}, - - // case-sensitivity: lowercase doesn't match - {root + "/Project-A/working", "mdedit", false}, - {root + "/Project-A/staging", "transmittal", false}, + {root + "/Project-A/STAGING", "transmittal", true}, + {root + "/Project-A/archive/ACME/Incoming", "classifier", true}, + {root + "/Project-A/Archive/ACME/incoming", "classifier", true}, // unknown app {root + "/Project-A", "weird", false}, diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 15407fc..a2f37d0 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -516,16 +516,16 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - // Auto-ownership: when the parent directory is one of the - // auto-own special folders (Incoming/Working/Staging) and the - // caller has an authenticated email, write a .zddc into the new - // folder granting the creator full control. The grant is identical - // to what the operator would write by hand — direct email pattern, - // "rwcda" verb set — so the creator can later edit the file - // normally to add collaborators. + // Auto-ownership: when the parent directory is one of the canonical + // auto-own positions (working/, staging/, archive//incoming/) + // and the caller has an authenticated email, write a .zddc into the + // new folder granting the creator full control. The grant is + // identical to what the operator would write by hand — direct email + // pattern, "rwcda" verb set — so the creator can later edit the + // file normally to add collaborators. if email := EmailFromContext(r); email != "" { - parentName := filepath.Base(filepath.Dir(abs)) - if zddc.IsAutoOwnParent(parentName) { + parentDir := filepath.Dir(abs) + if zddc.IsAutoOwnPath(parentDir, cfg.Root) { if err := zddc.WriteAutoOwnZddc(abs, email); err != nil { slog.Warn("auto-own .zddc write failed", "path", abs, "err", err) } diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 45561cc..8393696 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -378,19 +378,22 @@ func TestFileAPI_AnonymousDenied(t *testing.T) { } } -// rolePermissionsTestSetup creates a vendor-exchange shape: +// rolePermissionsTestSetup creates a project + per-party exchange shape: // -// root .zddc: _company:r, _doc_controller:rwcda -// Vendor/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:"" +// root .zddc: _company:r, _doc_controller:rwcda +// /archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:"" // roles defined at root. // +// The project is "Project-X"; the counterparty is "Acme". URLs target +// paths like /Project-X/archive/Acme/incoming/. +// // Returns the same do() helper as fileAPITestSetup. func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) { t.Helper() root = t.TempDir() // Root .zddc — company gets r, doc_controller gets rwcda. Roles - // defined here so the vendor subtree's permissions can reference + // defined here so the per-party subtree's permissions can reference // them by name. rootZ := []byte(`roles: _company: @@ -408,25 +411,21 @@ acl: t.Fatalf("root .zddc: %v", err) } - // Vendor subtree: narrow scope. - vendorDir := filepath.Join(root, "Vendor") - if err := os.MkdirAll(filepath.Join(vendorDir, "Incoming"), 0o755); err != nil { - t.Fatalf("mkdir Vendor/Incoming: %v", err) + // Project + per-party canonical layout. + partyDir := filepath.Join(root, "Project-X", "archive", "Acme") + for _, sub := range []string{"incoming", "issued", "received"} { + if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil { + t.Fatalf("mkdir party/%s: %v", sub, err) + } } - if err := os.MkdirAll(filepath.Join(vendorDir, "Issued"), 0o755); err != nil { - t.Fatalf("mkdir Vendor/Issued: %v", err) - } - if err := os.MkdirAll(filepath.Join(vendorDir, "Received"), 0o755); err != nil { - t.Fatalf("mkdir Vendor/Received: %v", err) - } - vendorZ := []byte(`acl: + partyZ := []byte(`acl: permissions: vendor_acme: rwcd _doc_controller: rwcda _company: "" `) - if err := os.WriteFile(filepath.Join(vendorDir, ".zddc"), vendorZ, 0o644); err != nil { - t.Fatalf("vendor .zddc: %v", err) + if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil { + t.Fatalf("party .zddc: %v", err) } zddc.InvalidateCache(root) @@ -462,84 +461,84 @@ acl: func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) { _, do, _ := rolePermissionsTestSetup(t) - // Vendor PUTs into their Incoming → 201. - rec := do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data"), nil) + // Vendor PUTs into their incoming → 201. + rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil) if rec.Code != http.StatusCreated { - t.Fatalf("PUT vendor → Incoming: want 201, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String()) } // Vendor overwrites the same file → 200 (rwcd has w). - rec = do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil) + rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil) if rec.Code != http.StatusOK { - t.Fatalf("PUT vendor → Incoming overwrite: want 200, got %d", rec.Code) + t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code) } } func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Seed an existing Issued file. - if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/spec.pdf"), []byte("FILED"), 0o644); err != nil { + // Seed an existing issued file. + if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil { t.Fatalf("seed: %v", err) } - // Vendor cannot overwrite — ancestor grant masked to r in Issued. - rec := do(http.MethodPut, "/Vendor/Issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil) + // Vendor cannot overwrite — ancestor grant masked to r in issued. + rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil) if rec.Code != http.StatusForbidden { - t.Fatalf("PUT vendor → Issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String()) } // Vendor cannot delete. - rec = do(http.MethodDelete, "/Vendor/Issued/spec.pdf", "rep@acme.com", nil, nil) + rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil) if rec.Code != http.StatusForbidden { - t.Fatalf("DELETE vendor → Issued: want 403, got %d", rec.Code) + t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code) } // Vendor cannot create new files — they have no explicit .zddc grant - // at the Issued folder, so the WORM split reduces their inherited + // at the issued folder, so the WORM split reduces their inherited // rwcd to r-only. - rec = do(http.MethodPut, "/Vendor/Issued/new.pdf", "rep@acme.com", []byte("x"), nil) + rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil) if rec.Code != http.StatusForbidden { - t.Fatalf("PUT vendor → Issued (create): want 403 (no explicit grant at Issued), got %d", rec.Code) + t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code) } } func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Without a .zddc at Vendor/Issued/ explicitly granting cr, the dc's - // inherited rwcda is masked to r. They cannot create. - rec := do(http.MethodPut, "/Vendor/Issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil) + // Without a .zddc at archive/Acme/issued/ explicitly granting cr, + // the dc's inherited rwcda is masked to r. They cannot create. + rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil) if rec.Code != http.StatusForbidden { - t.Fatalf("dc without explicit grant → Issued: want 403, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String()) } - // Operator places an explicit grant at Vendor/Issued/.zddc. Now dc - // has cr at-or-below the WORM folder, which survives the mask. + // Operator places an explicit grant at archive/Acme/issued/.zddc. + // Now dc has cr at-or-below the WORM folder, which survives the mask. issuedZ := []byte(`acl: permissions: _doc_controller: cr `) - if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil { - t.Fatalf("write Issued .zddc: %v", err) + if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { + t.Fatalf("write issued .zddc: %v", err) } zddc.InvalidateCache(root) - rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil) + rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil) if rec.Code != http.StatusCreated { - t.Fatalf("dc with explicit grant → Issued: want 201, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String()) } - got, _ := os.ReadFile(filepath.Join(root, "Vendor/Issued/2026-Q2-spec.pdf")) + got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf")) if string(got) != "CONTROLLED" { t.Fatalf("body: %q", got) } // dc still cannot overwrite — explicit grant is cr, no w. - rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil) + rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil) if rec.Code != http.StatusForbidden { - t.Fatalf("dc PUT overwrite → Issued: want 403, got %d", rec.Code) + t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code) } // dc still cannot delete. - rec = do(http.MethodDelete, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil) + rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil) if rec.Code != http.StatusForbidden { - t.Fatalf("dc DELETE → Issued: want 403, got %d", rec.Code) + t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code) } } @@ -554,29 +553,29 @@ func TestFileAPI_WORM_AdminBypass(t *testing.T) { } zddc.InvalidateCache(cfg.Root) - // Seed an Issued file and have root@ delete it (escape hatch). - if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/mistake.pdf"), []byte("oops"), 0o644); err != nil { + // Seed an issued file and have root@ delete it (escape hatch). + if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil { t.Fatalf("seed: %v", err) } - rec := do(http.MethodDelete, "/Vendor/Issued/mistake.pdf", "root@example.com", nil, nil) + rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil) if rec.Code != http.StatusNoContent { - t.Fatalf("admin DELETE → Issued: want 204, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String()) } } func TestFileAPI_AutoMkdirOwnership(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Vendor creates a folder under their Incoming. Server should + // Vendor creates a folder under their incoming. Server should // auto-write a .zddc granting them rwcda on the new subtree. - rec := do(http.MethodPost, "/Vendor/Incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{ + rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusCreated { t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String()) } - autoZ := filepath.Join(root, "Vendor/Incoming/2026-05-15-issue/.zddc") + autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc") data, err := os.ReadFile(autoZ) if err != nil { t.Fatalf("auto .zddc not written: %v", err) @@ -595,7 +594,7 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) { // now PUT a brand-new file inside their owned folder where they // otherwise wouldn't have ACL admin rights. zddc.InvalidateCache(root) - rec = do(http.MethodPut, "/Vendor/Incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil) + rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil) if rec.Code != http.StatusCreated { t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String()) } @@ -604,25 +603,25 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) { func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { _, do, root := rolePermissionsTestSetup(t) - // Place an explicit grant so dc has cr at the Issued level. + // Place an explicit grant so dc has cr at the issued level. issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n") - if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil { - t.Fatalf("seed Issued .zddc: %v", err) + if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil { + t.Fatalf("seed issued .zddc: %v", err) } zddc.InvalidateCache(root) - // Doc controller mkdir under Issued — should succeed (cr survives mask) - // but should NOT auto-write an ownership .zddc (Issued is excluded + // Doc controller mkdir under issued — should succeed (cr survives mask) + // but should NOT auto-write an ownership .zddc (issued is excluded // from auto-own). - rec := do(http.MethodPost, "/Vendor/Issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{ + rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusCreated { t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String()) } - autoZ := filepath.Join(root, "Vendor/Issued/2026-Q2/.zddc") + autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc") if _, err := os.Stat(autoZ); !os.IsNotExist(err) { - t.Errorf("auto .zddc should NOT be written under Issued; got err=%v", err) + t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err) } } @@ -657,9 +656,9 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) { return rec } - // Vendor's leaf rwcd grant in Vendor/.zddc is overridden by the - // root deny under strict mode. - rec := doStrict(http.MethodPut, "/Vendor/Incoming/blocked.pdf", "rep@acme.com", []byte("nope")) + // Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by + // the root deny under strict mode. + rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope")) if rec.Code != http.StatusForbidden { t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String()) } diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index aea7715..671e0aa 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -49,28 +49,6 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} // MkdirAll for it. var VirtualOnlyCanonicalNames = []string{"reviewing"} -// SpecialFolderNames is preserved for callers that still reference the -// pre-canonical-folders model. Equivalent to the union of project-root -// AutoOwnCanonicalNames-at-root + WORM-canonical-names-at-party with -// the legacy PascalCase spellings, kept temporarily so dependent -// packages compile during the migration. New code should reference -// ProjectRootFolders / PartyFolders directly. -// -// Deprecated: use ProjectRootFolders or PartyFolders. -var SpecialFolderNames = []string{"Incoming", "Working", "Staging", "Issued", "Received"} - -// AutoOwnFolderNames is the legacy capitalised list. Predicates have -// moved to IsAutoOwnPath which understands the new layout. -// -// Deprecated: use AutoOwnCanonicalNames. -var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"} - -// WormFolderNames is the legacy capitalised list. Predicates have -// moved to IsWormPath which understands the per-party layout. -// -// Deprecated: use PartyFolders + IsWormPath. -var WormFolderNames = []string{"Issued", "Received"} - // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting // principalEmail rwcda and recording it in CreatedBy. Used by the file // API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed @@ -124,34 +102,59 @@ func ResolveCanonical(parentDir, logical string) (string, error) { return "", nil } -// IsAutoOwnParent reports whether a folder named name should trigger -// the mkdir auto-ownership .zddc write when a child is created inside -// it. Used by the file API's mkdir handler. -func IsAutoOwnParent(name string) bool { - for _, n := range AutoOwnFolderNames { - if name == n { - return true - } +// IsAutoOwnPath reports whether parentDir is one of the canonical +// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir +// inside such a directory should receive a creator-owned .zddc. +// +// Canonical positions, relative to fsRoot: +// +// - /working +// - /staging +// - /archive//incoming +// +// Segment matches are case-insensitive on canonical names. The project +// and party names are unrestricted. +// +// parentDir and fsRoot are filesystem paths. parentDir must be inside +// fsRoot; otherwise the function returns false. +func IsAutoOwnPath(parentDir, fsRoot string) bool { + rel, err := filepath.Rel(fsRoot, parentDir) + if err != nil { + return false + } + rel = filepath.ToSlash(rel) + if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." { + return false + } + parts := strings.Split(rel, "/") + switch len(parts) { + case 2: + // /working or /staging + return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging") + case 4: + // /archive//incoming + return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming") } return false } -// IsWormPath reports whether requestPath is inside an "Issued" or -// "Received" subtree. The check is purely on path segments — a file -// named "Issued.txt" does not trigger WORM, but -// "/Project/Vendor/Issued/foo.pdf" does, as does -// "/Project/Vendor/Issued/" itself. requestPath may be a URL path -// ("/foo/bar") or a filesystem path; only segment names matter. +// IsWormPath reports whether requestPath crosses an +// archive//received/ or archive//issued/ segment chain. +// Pure path-segment check; case-fold on canonical names. +// +// The party segment is unrestricted — any directory under archive/ is +// treated as a party, including the self-folder. requestPath may be a +// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem +// path; only segment names matter. func IsWormPath(requestPath string) bool { - clean := strings.Trim(filepath.ToSlash(requestPath), "/") - if clean == "" { - return false - } - for _, seg := range strings.Split(clean, "/") { - for _, name := range WormFolderNames { - if seg == name { - return true - } + parts := splitPathSegments(requestPath) + for i := 0; i+2 < len(parts); i++ { + if !strings.EqualFold(parts[i], "archive") { + continue + } + // parts[i+1] is the party name (anything). + if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { + return true } } return false @@ -165,41 +168,52 @@ func IsWormPath(requestPath string) bool { // are the deliberate escape hatch for mis-filed documents. // // The WORM mask is split-aware via WormFolderLevelIndex: grants -// inherited from ancestors above the Issued/Received folder are +// inherited from ancestors above the received/issued folder are // masked to read only ({r}), while grants at-or-below the WORM // folder retain {r, c} so an operator can place a .zddc at the -// Issued folder explicitly granting `_doc_controller: cr`. +// received/issued folder explicitly granting `_doc_controller: cr`. func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC } // WormFolderLevelIndex returns the chain index of the deepest -// "Issued" or "Received" segment in requestPath. The chain +// archive//(received|issued) segment in requestPath. The chain // corresponds to the directory tree from root (index 0) to the // requested directory; level i is the .zddc at path segment depth i. // -// numLevels is len(chain.Levels); used to clamp results to the -// chain's actual range (e.g. a request to a file inside an Issued -// folder has a chain that only covers up to the Issued directory, -// not the file itself). +// numLevels is len(chain.Levels); used to clamp results to the chain's +// actual range. URL segment i lives at chain index i+1 (root is chain +// index 0), so the WORM segment at parts[i+2] corresponds to chain +// index i+3. // -// Returns -1 if no WORM segment is in the request path or the -// computed index is out of range. The returned index satisfies +// Returns -1 if no WORM segment is in the request path or the computed +// index is out of range. The returned index satisfies // 0 <= index < numLevels. func WormFolderLevelIndex(requestPath string, numLevels int) int { - clean := strings.Trim(filepath.ToSlash(requestPath), "/") - if clean == "" || numLevels <= 0 { + if numLevels <= 0 { return -1 } + parts := splitPathSegments(requestPath) deepest := -1 - for i, seg := range strings.Split(clean, "/") { - for _, name := range WormFolderNames { - if seg == name { - // URL segment i lives at chain index i+1 (root is index 0). - idx := i + 1 - if idx < numLevels && idx > deepest { - deepest = idx - } + for i := 0; i+2 < len(parts); i++ { + if !strings.EqualFold(parts[i], "archive") { + continue + } + if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { + idx := i + 3 + if idx < numLevels && idx > deepest { + deepest = idx } } } return deepest } + +// splitPathSegments returns the slash-separated segments of p with +// empty elements removed. Tolerates leading/trailing slashes and +// mixed separators on Windows (via filepath.ToSlash). +func splitPathSegments(p string) []string { + clean := strings.Trim(filepath.ToSlash(p), "/") + if clean == "" { + return nil + } + return strings.Split(clean, "/") +} diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index 9f2d542..db3c21e 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -6,34 +6,73 @@ import ( "testing" ) -func TestIsAutoOwnParent(t *testing.T) { - yes := []string{"Incoming", "Working", "Staging"} - no := []string{"Issued", "Received", "incoming", "Random", "", "Working/sub"} - for _, n := range yes { - if !IsAutoOwnParent(n) { - t.Errorf("IsAutoOwnParent(%q) = false, want true", n) - } +func TestIsAutoOwnPath(t *testing.T) { + root := "/srv/zddc" + cases := map[string]bool{ + // Project-root canonical positions. + "/srv/zddc/Project/working": true, + "/srv/zddc/Project/staging": true, + "/srv/zddc/Project/Working": true, // case-fold + "/srv/zddc/Project/STAGING": true, // case-fold + "/srv/zddc/Project/archive": false, + "/srv/zddc/Project/reviewing": false, + "/srv/zddc/Project/random": false, + + // Per-party position. + "/srv/zddc/Project/archive/ACME/incoming": true, + "/srv/zddc/Project/archive/ACME/Incoming": true, // case-fold + "/srv/zddc/Project/Archive/ACME/incoming": true, // case-fold archive + "/srv/zddc/Project/archive/ACME/received": false, + "/srv/zddc/Project/archive/ACME/issued": false, + "/srv/zddc/Project/archive/ACME/mdl": false, + + // Wrong depth — incoming inside something other than archive//. + "/srv/zddc/Project/working/incoming": false, + "/srv/zddc/Project/random/sub/incoming": false, + "/srv/zddc/Project/incoming": false, // depth 1 with incoming + "/srv/zddc/Project/archive/incoming": false, // depth 2 + "/srv/zddc/Project/archive/ACME/incoming/sub": false, // child of incoming, not incoming itself + + // Outside root. + "/elsewhere/working": false, + // Root itself or one above. + "/srv/zddc": false, + "/srv/zddc/Project": false, } - for _, n := range no { - if IsAutoOwnParent(n) { - t.Errorf("IsAutoOwnParent(%q) = true, want false", n) + for in, want := range cases { + if got := IsAutoOwnPath(in, root); got != want { + t.Errorf("IsAutoOwnPath(%q, %q) = %v, want %v", in, root, got, want) } } } func TestIsWormPath(t *testing.T) { cases := map[string]bool{ - "": false, - "/": false, - "/Project/Issued": true, - "/Project/Issued/": true, - "/Project/Issued/file.pdf": true, - "/Project/Issued/sub/file.pdf": true, - "/Project/Vendor/Issued/x.pdf": true, - "/Project/Vendor/Received/y": true, - "/Project/Working/draft.md": false, - "/Project/Working/Issued.txt": false, // file named Issued.txt — not a path segment - "/Project/issued/lower.pdf": false, // lowercase ≠ Issued + "": false, + "/": false, + "/Project/archive/ACME/issued": true, + "/Project/archive/ACME/issued/": true, + "/Project/archive/ACME/issued/foo.pdf": true, + "/Project/archive/ACME/received/x": true, + "/Project/archive/ACME/Issued/x": true, // case-fold + "/Project/Archive/ACME/issued/x": true, // case-fold + "/Project/archive/ACME/ISSUED/x": true, // case-fold + + // Per-party MDL/incoming aren't WORM. + "/Project/archive/ACME/incoming/x": false, + "/Project/archive/ACME/mdl/x": false, + + // Bare "issued" or "received" not under archive// — no WORM. + "/Project/issued/x": false, + "/Project/received/x": false, + "/Project/working/issued.md": false, // file basename, not a path segment match + "/Project/working/issued": false, // "working" is not "archive" + + // Self-folder is symmetric (any party name works). + "/Project/archive/Self-Org/issued/x.pdf": true, + + // Nested or deep. + "/multi/Project/archive/Vendor/received/sub/file.pdf": true, } for in, want := range cases { if got := IsWormPath(in); got != want { @@ -42,6 +81,36 @@ func TestIsWormPath(t *testing.T) { } } +func TestWormFolderLevelIndex(t *testing.T) { + // Path /Project/archive/ACME/issued/foo.pdf + // parts: [Project, archive, ACME, issued, foo.pdf] + // issued is segment index 3, chain index 4. + if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 6); got != 4 { + t.Errorf("issued at depth 4: got %d, want 4", got) + } + // Same path, but the chain only has 4 levels (numLevels=4 → idx must be < 4). + if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 4); got != -1 { + t.Errorf("clamp: got %d, want -1", got) + } + // No WORM segment. + if got := WormFolderLevelIndex("/Project/working/foo.md", 5); got != -1 { + t.Errorf("no worm: got %d, want -1", got) + } + // Empty. + if got := WormFolderLevelIndex("", 5); got != -1 { + t.Errorf("empty: got %d, want -1", got) + } + // Nested archive//issued — deepest wins. + // parts: [P, archive, A, received, archive, B, issued, x] + // indices: 0 1 2 3 4 5 6 7 + // outer match: i=1 (archive), parts[3]=received → segment idx 3, chain idx 4 + // inner match: i=4 (archive), parts[6]=issued → segment idx 6, chain idx 7 + // deepest = 7. + if got := WormFolderLevelIndex("/P/archive/A/received/archive/B/issued/x", 12); got != 7 { + t.Errorf("nested: got %d, want 7", got) + } +} + func TestWormMaskStripsWDA(t *testing.T) { rwcda, _ := ParseVerbSet("rwcda") masked := WormMask(rwcda) @@ -107,17 +176,29 @@ func TestResolveCanonicalMissingParent(t *testing.T) { } } -func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) { - want := map[string]bool{ - "Incoming": false, "Working": false, "Staging": false, - "Issued": false, "Received": false, - } - for _, n := range SpecialFolderNames { - want[n] = true - } - for n, present := range want { - if !present { - t.Errorf("SpecialFolderNames missing %q", n) +func TestCanonicalLists(t *testing.T) { + hasAll := func(have, want []string) bool { + set := map[string]bool{} + for _, n := range have { + set[n] = true } + for _, n := range want { + if !set[n] { + return false + } + } + return true + } + if !hasAll(ProjectRootFolders, []string{"archive", "working", "staging", "reviewing"}) { + t.Errorf("ProjectRootFolders = %v, missing entries", ProjectRootFolders) + } + if !hasAll(PartyFolders, []string{"mdl", "incoming", "received", "issued"}) { + t.Errorf("PartyFolders = %v, missing entries", PartyFolders) + } + if !hasAll(AutoOwnCanonicalNames, []string{"working", "staging", "incoming"}) { + t.Errorf("AutoOwnCanonicalNames = %v, missing entries", AutoOwnCanonicalNames) + } + if !hasAll(VirtualOnlyCanonicalNames, []string{"reviewing"}) { + t.Errorf("VirtualOnlyCanonicalNames = %v, missing entries", VirtualOnlyCanonicalNames) } }