diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index fc4004b..0cf7f0b 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -610,19 +610,29 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - // Auto-ownership for the newly-created directory itself. - // - // Two cases yield an auto-own .zddc inside abs: - // - The new directory is itself a canonical auto-own position - // (e.g. an explicit MKCOL of /Project/working). In this case - // IsAutoOwnPath(abs, cfg.Root) is true. - // - The new directory's parent is canonical auto-own — every child - // mkdir under working/, staging/, or archive//incoming/ - // gets the creator's grant. + // Auto-ownership for the newly-created directory. The .zddc + // cascade's `auto_own:` flag (see defaults.zddc.yaml) drives this, + // same as EnsureCanonicalAncestors. A creator-owned .zddc lands + // inside abs when: + // - abs itself is declared auto_own (e.g. an explicit mkdir of + // /Project/working), or + // - abs's parent is declared auto_own — every child mkdir under + // an auto-own folder (working/, staging/, archive//, + // archive//incoming/, …) gets the creator's grant. + // The fence (inherit:false) follows abs's own cascade level: + // per-user homes under working/ declare auto_own_fenced, so the + // generated .zddc is private; other auto-own positions are + // unfenced so ancestor grants still cascade through. if email != "" { - if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) { - if err := zddc.WriteAutoOwnZddc(abs, email); err != nil { - slog.Warn("auto-own .zddc write failed", "path", abs, "err", err) + if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) { + var werr error + if zddc.AutoOwnFencedAt(cfg.Root, abs) { + werr = zddc.WriteAutoOwnZddcFenced(abs, email) + } else { + werr = zddc.WriteAutoOwnZddc(abs, email) + } + if werr != nil { + slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr) } } } diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index cfd8307..52d93a1 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -2,7 +2,6 @@ package zddc import ( "os" - "path/filepath" "strings" ) @@ -88,51 +87,11 @@ func ResolveCanonical(parentDir, logical string) (string, error) { return "", nil } -// 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 / WormFolderLevelIndex retired in the cascade-config -// migration — WORM zones are declared via the `worm:` key on a -// directory's .zddc, resolved by WormZoneGrant in worm.go. -// defaults.zddc.yaml carries `worm: {}` on archive//received -// and archive//issued, so the canonical convention is -// unchanged; the difference is that an operator can now mark any -// directory WORM, or rename received/issued, without a code change.) - -// WormMask reduces a verb set to the subset that survives the WORM -// constraint: the bitwise AND with VerbsRC. Removes w, d, and a. -// Generic helper kept for callers that need the masking primitive. -func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC } +// Retired in the cascade-config migration — driven by .zddc keys now: +// - IsAutoOwnPath → the `auto_own:` flag, resolved by AutoOwnAt +// - IsWormPath / WormFolderLevelIndex / WormMask +// → the `worm:` list, resolved by WormZoneGrant +// defaults.zddc.yaml carries the canonical conventions (auto_own on +// working/staging/archive-party/incoming, worm: on received/issued), +// so behaviour is unchanged; the difference is an operator can +// reshape or rename any of it without a code change. diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index 17ab776..1aab83d 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -6,73 +6,11 @@ import ( "testing" ) -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 in, want := range cases { - if got := IsAutoOwnPath(in, root); got != want { - t.Errorf("IsAutoOwnPath(%q, %q) = %v, want %v", in, root, got, want) - } - } -} - -// TestIsWormPath / TestWormFolderLevelIndex retired — WORM zones are -// declared via the `worm:` key now (see worm.go's WormZoneGrant, -// exercised by worm_test.go). The convention (received/issued are -// WORM) lives in defaults.zddc.yaml and is asserted via the cascade -// lookup, not a path-segment predicate. - -func TestWormMaskStripsWDA(t *testing.T) { - rwcda, _ := ParseVerbSet("rwcda") - masked := WormMask(rwcda) - if got := masked.String(); got != "rc" { - t.Errorf("WormMask(rwcda) = %q, want rc", got) - } - - rw, _ := ParseVerbSet("rw") - if got := WormMask(rw).String(); got != "r" { - t.Errorf("WormMask(rw) = %q, want r", got) - } - - cd, _ := ParseVerbSet("cd") - if got := WormMask(cd).String(); got != "c" { - t.Errorf("WormMask(cd) = %q, want c", got) - } - - if got := WormMask(0).String(); got != "" { - t.Errorf("WormMask(0) = %q, want empty", got) - } -} +// TestIsAutoOwnPath / TestIsWormPath / TestWormFolderLevelIndex / +// TestWormMaskStripsWDA retired in the cascade-config migration. The +// `auto_own:` and `worm:` .zddc keys carry these conventions now — +// see lookups_test.go (AutoOwnAt) and worm_test.go (WormZoneGrant); +// the canonical defaults live in defaults.zddc.yaml. func TestResolveCanonicalCaseFold(t *testing.T) { dir := t.TempDir()