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 IsProjectPeer(seg) { if err := resolveAt(1, seg); err != nil { return target, err } } } if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) if IsPerPartySlot(seg) { 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 (the workspace party folders), // it also writes a creator-owned .zddc using principalEmail (skipped if // principalEmail is empty) — auto-own + fence are resolved per-dir via // the .zddc cascade (AutoOwnAt / AutoOwnFencedAt). // // 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 Archive/ is reused rather than shadowed by a // new archive/ sibling. The basename of target is never altered. // // Canonical positions, relative to fsRoot: // // - / for any top-level peer (IsProjectPeer: archive, // incoming, working, staging, reviewing, mdl, rsk, ssr) — all are // physical directories. // // - /archive// where ∈ {received, issued} // (IsPerPartySlot) — the WORM record folders. // // 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 } 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 fenced bool roles []string } 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: any top-level peer (all physical now). seg := strings.ToLower(parts[1]) if IsProjectPeer(seg) { if err := resolveAt(1, seg); err != nil { return target, err } } } // Depth 3 candidate (archive//): the WORM record slots // received/issued. Only meaningful when depth 1 is "archive". if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) if IsPerPartySlot(seg) { 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 and whether it should be fenced (inherit: false). // Resolved via the .zddc cascade — the embedded defaults // (internal/zddc/defaults/) declare auto_own at the working/ // staging/ incoming/ reviewing/ homes but do NOT fence // them (they are shared team folders); an on-disk .zddc can opt // a directory into fencing per-directory with auto_own_fenced. _ = parentSegs // depth-tracking no longer needed _ = i autoOwn := AutoOwnAt(fsRoot, pathSoFar) fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar) var roles []string if autoOwn { roles = AutoOwnRolesAt(fsRoot, pathSoFar) } freshlyCreated = append(freshlyCreated, created{ absPath: pathSoFar, autoOwn: autoOwn, fenced: fenced, roles: roles, }) } // Seed auto-own .zddc on the canonical positions that were freshly // created. Skip if no principal email is available (anonymous or // system writes). The fenced variant (inherit:false, private to the // creator) is an opt-in the default tree does not use — see // AutoOwnFencedAt. Role grants (from the cascade's auto_own_roles // list) are written alongside the creator email so role-level peer // authority survives without needing a subtree-admin grant. if principalEmail != "" { for _, c := range freshlyCreated { if !c.autoOwn { continue } var werr error if c.fenced { werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles) } else { werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles) } if werr != nil { return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr) } } } return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil } // (autoOwnDepthMatch / isAutoOwnDepthMatch removed in Phase 3c — // auto-own + fence determination now flows through the .zddc cascade // via AutoOwnAt / AutoOwnFencedAt.)