diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index a2f37d0..85e9590 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -275,6 +275,15 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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 // 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. @@ -308,6 +317,14 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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 { auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -420,6 +437,14 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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. srcInfo, err := os.Stat(srcAbs) if err != nil { @@ -458,7 +483,15 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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//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 { auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -493,6 +526,11 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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) { return } @@ -510,22 +548,33 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // Materialise any missing canonical ancestors (working/, staging/, + // archive//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 { auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - // 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 != "" { - parentDir := filepath.Dir(abs) - if zddc.IsAutoOwnPath(parentDir, cfg.Root) { + // 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. + 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) } diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go new file mode 100644 index 0000000..6855e33 --- /dev/null +++ b/zddc/internal/zddc/ensure.go @@ -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//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: +// +// - / where ∈ +// {archive, working, staging} +// - /archive// where +// ∈ {mdl, incoming, received, issued} +// +// "reviewing" is intentionally NOT created here — it's a purely virtual +// route. A write that targets a path under /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/; 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//): 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: + // /working or /staging + return strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") + case 3: + // /archive//incoming + return strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming") + } + return false +} diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go new file mode 100644 index 0000000..ed88163 --- /dev/null +++ b/zddc/internal/zddc/ensure_test.go @@ -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") + } +}