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>
246 lines
8.1 KiB
Go
246 lines
8.1 KiB
Go
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
|
|
}
|