chore(zddc): migrate mkdir auto-own hook to the cascade, drop dead predicates

The file API's mkdir post-hook still seeded auto-own .zddc files via the
hardcoded IsAutoOwnPath path-segment predicate, while
EnsureCanonicalAncestors had already moved to the cascade's auto_own:
flag. Point the hook at AutoOwnAt / AutoOwnFencedAt so both paths agree
and an operator's .zddc reshaping actually takes effect — fenced when
the new directory's own cascade level declares auto_own_fenced (per-user
working homes), unfenced otherwise.

Retires IsAutoOwnPath and WormMask (the latter already superseded by
WormZoneGrant's & VerbsRC) plus their tests, and the now-unused
path/filepath import in special.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 10:42:49 -05:00
parent 9aa587aac0
commit c8d0afd1b8
3 changed files with 35 additions and 128 deletions

View file

@ -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/<party>/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/<party>/,
// archive/<party>/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)
}
}
}

View file

@ -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:
//
// - <project>/working
// - <project>/staging
// - <project>/archive/<party>/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:
// <project>/working or <project>/staging
return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging")
case 4:
// <project>/archive/<party>/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/<party>/received
// and archive/<party>/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.

View file

@ -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/<party>/.
"/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()