feat(zddc): EnsureCanonicalAncestors lazy-creates canonical folders on write

New helper pair:
  - ResolveCanonicalPath(fsRoot, target)              — case-fold path resolution, no side effects
  - EnsureCanonicalAncestors(fsRoot, target, email…)  — case-fold + MkdirAll + auto-own .zddc seeding

For each canonical position along the requested path the helpers
substitute on-disk casing (so /Project/working/foo lands in an existing
Working/ rather than a new sibling) and materialise missing
working/staging/archive/<party>/{mdl,incoming,received,issued}/ folders.
working/, staging/, and archive/<party>/incoming/ get a creator-owned
.zddc seeded automatically; received/, issued/, and mdl/ are created
without auto-own (WORM and data-store concerns respectively).
reviewing/ is rejected — purely virtual, never on disk.

Wired into the file API:
  - serveFilePut          — resolve before auth, ensure after auth
  - serveFileMkdir        — resolve before auth, ensure after auth, with
                            two auto-own checks (target-is-canonical OR
                            parent-is-canonical)
  - serveFileMove (POST)  — resolve src+dst, ensure dst before rename so
                            a move from working/<draft> →
                            archive/<recipient>/issued/<draft> creates
                            the per-party folders on the way in

7 new unit tests in zddc/internal/zddc/ensure_test.go cover lazy
creation, case-fold reuse, per-party incoming auto-own, WORM no-auto-own,
empty-principal skip, reviewing rejection, and traversal rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-07 08:59:59 -05:00
parent b7e1a4310b
commit a79cfd2f88
3 changed files with 473 additions and 11 deletions

View file

@ -275,6 +275,15 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Resolve canonical-folder casing on the way in (no side effects): a
// request for /Project/working/foo.md when the on-disk folder is
// Working/ should land in Working/, not create a duplicate sibling.
// The actual MkdirAll for missing canonical ancestors and the
// auto-own .zddc seeding happen after authorisation, below.
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
abs = r2
}
// Stat first so we can choose action=create vs action=write before the // Stat first so we can choose action=create vs action=write before the
// ACL gate runs — this matters because role grants may include `c` but // ACL gate runs — this matters because role grants may include `c` but
// not `w` (or vice versa), and the gate must check the right verb. // not `w` (or vice versa), and the gate must check the right verb.
@ -308,6 +317,14 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Now that the write is authorized, materialise any missing canonical
// ancestors and seed auto-own .zddc files for them.
if email := EmailFromContext(r); email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
}
}
if err := zddc.WriteAtomic(abs, body); err != nil { if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -420,6 +437,14 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Resolve canonical-folder casing on src + dst (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
srcAbs = r2
}
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil {
dstAbs = r2
}
// Source must exist as a regular file. // Source must exist as a regular file.
srcInfo, err := os.Stat(srcAbs) srcInfo, err := os.Stat(srcAbs)
if err != nil { if err != nil {
@ -458,7 +483,15 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Ensure destination's parent directory exists. // Ensure destination's canonical ancestors are created (with auto-own
// .zddc seeding) before the rename. This lets a MOVE from working/foo
// → archive/<party>/issued/foo materialise the per-party folders on
// the way in.
if email := EmailFromContext(r); email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err)
}
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -493,6 +526,11 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Resolve canonical-folder casing on the way in (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
abs = r2
}
if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
return return
} }
@ -510,22 +548,33 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Materialise any missing canonical ancestors (working/, staging/,
// archive/<party>/incoming/) before creating the target itself. This
// also seeds auto-own .zddc on each newly-created canonical ancestor.
email := EmailFromContext(r)
if email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
}
}
if err := os.MkdirAll(abs, 0o755); err != nil { if err := os.MkdirAll(abs, 0o755); err != nil {
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err) auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Auto-ownership: when the parent directory is one of the canonical // Auto-ownership for the newly-created directory itself.
// auto-own positions (working/, staging/, archive/<party>/incoming/) //
// and the caller has an authenticated email, write a .zddc into the // Two cases yield an auto-own .zddc inside abs:
// new folder granting the creator full control. The grant is // - The new directory is itself a canonical auto-own position
// identical to what the operator would write by hand — direct email // (e.g. an explicit MKCOL of /Project/working). In this case
// pattern, "rwcda" verb set — so the creator can later edit the // IsAutoOwnPath(abs, cfg.Root) is true.
// file normally to add collaborators. // - The new directory's parent is canonical auto-own — every child
if email := EmailFromContext(r); email != "" { // mkdir under working/, staging/, or archive/<party>/incoming/
parentDir := filepath.Dir(abs) // gets the creator's grant.
if zddc.IsAutoOwnPath(parentDir, cfg.Root) { if email != "" {
if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil { if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err) slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
} }

View file

@ -0,0 +1,246 @@
package zddc
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// ResolveCanonicalPath substitutes on-disk casing for any canonical
// ancestor segment of target, without creating anything. Returns target
// unchanged when no case variant exists or when target is at fsRoot or
// outside it.
//
// Use this before authorization to make ACL lookups operate against the
// real on-disk path without side effects. EnsureCanonicalAncestors
// performs the same substitution AND creates missing ancestors —
// authorization should run between the two.
func ResolveCanonicalPath(fsRoot, target string) (string, error) {
rel, err := filepath.Rel(fsRoot, target)
if err != nil {
return target, err
}
rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
return target, nil
}
parts := strings.Split(rel, "/")
resolvedSegs := make([]string, len(parts))
copy(resolvedSegs, parts)
join := func(n int) string {
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
return filepath.Join(segs...)
}
resolveAt := func(n int, logical string) error {
parent := join(n)
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
return nil
}
actual, err := ResolveCanonical(parent, logical)
if err != nil {
return err
}
if actual != "" {
resolvedSegs[n] = actual
}
return nil
}
if len(parts) >= 2 {
seg := strings.ToLower(parts[1])
if seg == "archive" || seg == "working" || seg == "staging" {
if err := resolveAt(1, seg); err != nil {
return target, err
}
}
}
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3])
switch seg {
case "mdl", "incoming", "received", "issued":
if err := resolveAt(3, seg); err != nil {
return target, err
}
}
}
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
}
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
// creating any missing canonical-folder ancestor with MkdirAll(perm). For
// freshly-created auto-own ancestors (working/, staging/, or
// archive/<party>/incoming/), it also writes a creator-owned .zddc using
// principalEmail (skipped if principalEmail is empty).
//
// Returns the resolved version of target with on-disk casing substituted
// for any canonical ancestor whose disk variant differs from the requested
// casing — so a pre-existing Working/ is reused rather than shadowed by a
// new working/ sibling. The basename of target is never altered.
//
// Canonical positions, relative to fsRoot:
//
// - <project>/<canonical-root> where <canonical-root> ∈
// {archive, working, staging}
// - <project>/archive/<party>/<canonical-party> where
// <canonical-party> ∈ {mdl, incoming, received, issued}
//
// "reviewing" is intentionally NOT created here — it's a purely virtual
// route. A write that targets a path under <project>/reviewing/ returns
// an error (callers should reject before invoking this helper).
//
// fsRoot and target must be absolute filesystem paths under the same
// volume; target may not yet exist on disk.
func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.FileMode) (string, error) {
rel, err := filepath.Rel(fsRoot, target)
if err != nil {
return target, fmt.Errorf("rel: %w", err)
}
rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
return target, fmt.Errorf("target %q escapes fsRoot %q", target, fsRoot)
}
parts := strings.Split(rel, "/")
if len(parts) < 2 {
// target is at fsRoot/<single-segment>; no canonical ancestors apply.
return target, nil
}
// Reject writes under reviewing/ — virtual route.
if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") {
return target, fmt.Errorf("reviewing/ is virtual and not writable")
}
resolvedSegs := make([]string, len(parts))
copy(resolvedSegs, parts)
// Track which ancestor directories we end up creating so we can seed
// auto-own .zddc files on the right ones afterwards.
type created struct {
absPath string
autoOwn bool
}
var freshlyCreated []created
// joinUnder builds an absolute path from fsRoot + the first n resolved
// segments.
joinUnder := func(n int) string {
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
return filepath.Join(segs...)
}
// resolveAt(n) tries to use the on-disk casing for resolvedSegs[n] inside
// joinUnder(n), substituting if a case-variant directory exists.
resolveAt := func(n int, logical string) error {
parent := joinUnder(n)
// Only substitute if the requested segment doesn't already match
// on disk (cheap optimisation to avoid a ReadDir on the hot path).
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
return nil
}
actual, err := ResolveCanonical(parent, logical)
if err != nil {
return err
}
if actual != "" {
resolvedSegs[n] = actual
}
return nil
}
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
// Depth 0 is the project segment; not a canonical name.
if len(parts) >= 2 {
// Depth 1 candidate: archive / working / staging.
seg := strings.ToLower(parts[1])
if seg == "archive" || seg == "working" || seg == "staging" {
if err := resolveAt(1, seg); err != nil {
return target, err
}
}
}
// Depth 3 candidate (archive/<party>/<canonical-party>): mdl / incoming /
// received / issued. Only meaningful when depth 1 is "archive".
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3])
switch seg {
case "mdl", "incoming", "received", "issued":
if err := resolveAt(3, seg); err != nil {
return target, err
}
}
}
// Now create any missing ancestors. We MkdirAll up to (but not
// including) the basename. The handler's actual write call still does
// its own parent-dir creation; this is the proactive seeding pass.
parentDir := filepath.Dir(filepath.Join(append([]string{fsRoot}, resolvedSegs...)...))
rootRel, _ := filepath.Rel(fsRoot, parentDir)
rootRel = filepath.ToSlash(rootRel)
if rootRel == "." || strings.HasPrefix(rootRel, "../") {
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
}
// Walk segment-by-segment. For each ancestor that doesn't exist yet,
// create it and record whether the position is auto-own.
pathSoFar := fsRoot
parentSegs := strings.Split(rootRel, "/")
for i, name := range parentSegs {
pathSoFar = filepath.Join(pathSoFar, name)
if info, err := os.Stat(pathSoFar); err == nil {
if !info.IsDir() {
return target, fmt.Errorf("ancestor %q exists but is not a directory", pathSoFar)
}
continue
} else if !os.IsNotExist(err) {
return target, err
}
if err := os.MkdirAll(pathSoFar, perm); err != nil {
return target, err
}
// Determine if this newly-created ancestor is an auto-own position.
// 'i' is the index into parentSegs which corresponds to depth i+1
// from fsRoot (parentSegs[0] is the project segment).
freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar,
autoOwn: isAutoOwnDepthMatch(parentSegs, i),
})
}
// Seed auto-own .zddc on the canonical positions that were freshly
// created. Skip if no principal email is available (anonymous or
// system writes).
if principalEmail != "" {
for _, c := range freshlyCreated {
if !c.autoOwn {
continue
}
if err := WriteAutoOwnZddc(c.absPath, principalEmail); err != nil {
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, err)
}
}
}
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
}
// isAutoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical
// auto-own depth. parentSegs is the slash-relative path from project root
// onward (so parentSegs[0] is the project segment).
func isAutoOwnDepthMatch(parentSegs []string, idx int) bool {
switch idx {
case 1:
// <project>/working or <project>/staging
return strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging")
case 3:
// <project>/archive/<party>/incoming
return strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming")
}
return false
}

View file

@ -0,0 +1,167 @@
package zddc
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md")
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if resolved != target {
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
}
// working/ is now created with auto-own .zddc.
autoZ := filepath.Join(root, "Proj", "working", ".zddc")
data, err := os.ReadFile(autoZ)
if err != nil {
t.Fatalf("auto-own .zddc not written at working/: %v", err)
}
body := string(data)
if !strings.Contains(body, "alice@x.com: rwcda") {
t.Errorf("auto-own grant missing: %s", body)
}
if !strings.Contains(body, "created_by: alice@x.com") {
t.Errorf("created_by missing: %s", body)
}
// alice@x.com/ subfolder also exists, no auto-own (it's not a canonical
// position — it's a regular subdir under working/).
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
t.Errorf("subfolder not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")); !os.IsNotExist(err) {
t.Errorf("subfolder should NOT have auto-own .zddc; got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
root := t.TempDir()
// Pre-create Working/ (PascalCase).
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "Proj", "working", "foo.md")
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err != nil {
t.Fatalf("ensure: %v", err)
}
// Resolved path uses on-disk Working/ casing.
want := filepath.Join(root, "Proj", "Working", "foo.md")
if resolved != want {
t.Errorf("resolved=%q, want %q", resolved, want)
}
// No new working/ sibling.
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) {
t.Errorf("lowercase sibling should not exist; got err=%v", err)
}
// Working/ already existed before our call — no auto-own .zddc was
// retroactively written.
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "archive", "ACME", "incoming", "submission.pdf")
_, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755)
if err != nil {
t.Fatalf("ensure: %v", err)
}
// archive/ created (no auto-own).
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
t.Errorf("archive/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", ".zddc")); !os.IsNotExist(err) {
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
}
// archive/ACME/ created (no auto-own — it's a party folder, not canonical).
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME")); err != nil {
t.Errorf("ACME/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", ".zddc")); !os.IsNotExist(err) {
t.Errorf("ACME/ should not have auto-own .zddc; got err=%v", err)
}
// archive/ACME/incoming/ created WITH auto-own.
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "incoming", ".zddc")
data, err := os.ReadFile(autoZ)
if err != nil {
t.Fatalf("auto-own .zddc at incoming/ not written: %v", err)
}
if !strings.Contains(string(data), "rep@acme.com: rwcda") {
t.Errorf("incoming/ auto-own missing rep grant: %s", data)
}
}
func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "archive", "ACME", "issued", "spec.pdf")
_, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued")); err != nil {
t.Errorf("issued/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued", ".zddc")); !os.IsNotExist(err) {
t.Errorf("issued/ should NOT have auto-own .zddc (WORM); got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "anon.md")
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
t.Errorf("working/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "reviewing", "x.md")
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err == nil {
t.Errorf("expected error for write under reviewing/, got nil")
}
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) {
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
root := t.TempDir()
other := t.TempDir()
target := filepath.Join(other, "evil.md")
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err == nil {
t.Errorf("expected error for target outside fsRoot")
}
}