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:
parent
9aa587aac0
commit
c8d0afd1b8
3 changed files with 35 additions and 128 deletions
|
|
@ -610,19 +610,29 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-ownership for the newly-created directory itself.
|
// Auto-ownership for the newly-created directory. The .zddc
|
||||||
//
|
// cascade's `auto_own:` flag (see defaults.zddc.yaml) drives this,
|
||||||
// Two cases yield an auto-own .zddc inside abs:
|
// same as EnsureCanonicalAncestors. A creator-owned .zddc lands
|
||||||
// - The new directory is itself a canonical auto-own position
|
// inside abs when:
|
||||||
// (e.g. an explicit MKCOL of /Project/working). In this case
|
// - abs itself is declared auto_own (e.g. an explicit mkdir of
|
||||||
// IsAutoOwnPath(abs, cfg.Root) is true.
|
// /Project/working), or
|
||||||
// - The new directory's parent is canonical auto-own — every child
|
// - abs's parent is declared auto_own — every child mkdir under
|
||||||
// mkdir under working/, staging/, or archive/<party>/incoming/
|
// an auto-own folder (working/, staging/, archive/<party>/,
|
||||||
// gets the creator's grant.
|
// 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 email != "" {
|
||||||
if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
|
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
|
var werr error
|
||||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package zddc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,51 +87,11 @@ func ResolveCanonical(parentDir, logical string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAutoOwnPath reports whether parentDir is one of the canonical
|
// Retired in the cascade-config migration — driven by .zddc keys now:
|
||||||
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
|
// - IsAutoOwnPath → the `auto_own:` flag, resolved by AutoOwnAt
|
||||||
// inside such a directory should receive a creator-owned .zddc.
|
// - IsWormPath / WormFolderLevelIndex / WormMask
|
||||||
//
|
// → the `worm:` list, resolved by WormZoneGrant
|
||||||
// Canonical positions, relative to fsRoot:
|
// defaults.zddc.yaml carries the canonical conventions (auto_own on
|
||||||
//
|
// working/staging/archive-party/incoming, worm: on received/issued),
|
||||||
// - <project>/working
|
// so behaviour is unchanged; the difference is an operator can
|
||||||
// - <project>/staging
|
// reshape or rename any of it without a code change.
|
||||||
// - <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 }
|
|
||||||
|
|
|
||||||
|
|
@ -6,73 +6,11 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsAutoOwnPath(t *testing.T) {
|
// TestIsAutoOwnPath / TestIsWormPath / TestWormFolderLevelIndex /
|
||||||
root := "/srv/zddc"
|
// TestWormMaskStripsWDA retired in the cascade-config migration. The
|
||||||
cases := map[string]bool{
|
// `auto_own:` and `worm:` .zddc keys carry these conventions now —
|
||||||
// Project-root canonical positions.
|
// see lookups_test.go (AutoOwnAt) and worm_test.go (WormZoneGrant);
|
||||||
"/srv/zddc/Project/working": true,
|
// the canonical defaults live in defaults.zddc.yaml.
|
||||||
"/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCanonicalCaseFold(t *testing.T) {
|
func TestResolveCanonicalCaseFold(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue