ZDDC/zddc/internal/zddc/ensure.go
ZDDC 5e393cbeaf feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.

Schema added:
  available_tools: [tool1, tool2, ...]   concat-union across cascade;
                                          tools not in the union are
                                          denied auto-route at that path
  auto_own_fenced: true|false             generated auto-own .zddc
                                          carries inherit:false (private
                                          to creator)

Lookups added:
  AvailableToolsAt(root, dir)   union of available_tools across cascade
  IsToolAvailableAt(root, dir, tool)
  AutoOwnFencedAt(root, dir)    leaf-only

Cascade semantics finalised (per field):
  default_tool      → leaf→root walk (parent applies to descendants)
  available_tools   → leaf→root union (each level adds; baseline at root)
  auto_own          → leaf-only (creating THIS dir specifically)
  auto_own_fenced   → leaf-only (same)
  virtual           → leaf-only (THIS dir is virtual, not subtree)

Consumers migrated:
  apps.DefaultAppAt        → zddc.DefaultToolAt
  apps.AppAvailableAt      → zddc.IsToolAvailableAt (+ landing special)
  EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
  fs.ListDirectory empty-list fallback     → zddc.IsDeclaredPath
  fs.virtualCanonicalFolders               → zddc.ChildrenDeclaredAt
  dispatcher canonical-folder branches     → unified into one
                                              cascade-declared block

Hardcoded helpers REMOVED (dead code):
  apps.inAncestorWithName
  zddc.autoOwnDepthMatch / isAutoOwnDepthMatch

Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
  ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
  VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
  IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
  is used by special.go's IsProjectRootFolder. The rest are dead.

Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
  - no-slash, default_tool=tables  → ServeTable (default-MDL fallback)
  - no-slash, default_tool set     → apps.Serve(tool)
  - no-slash, no default_tool      → 302 to slash form
  - slash, any                     → ServeDirectory empty-list fallback

The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.

defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.

Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.

Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:36:33 -05:00

252 lines
8.3 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
fenced 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 and whether it should be fenced (inherit: false).
// Resolved via the .zddc cascade — defaults.zddc.yaml
// carries the canonical "working/staging auto-own + per-user
// homes fenced + incoming auto-own" convention, and any
// on-disk .zddc can override per-directory.
_ = parentSegs // depth-tracking no longer needed
_ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar,
autoOwn: autoOwn,
fenced: fenced,
})
}
// 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 is used at per-user home
// folders under working/ — private by default; owner can later
// edit the .zddc to add collaborators.
if principalEmail != "" {
for _, c := range freshlyCreated {
if !c.autoOwn {
continue
}
var werr error
if c.fenced {
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail)
} else {
werr = WriteAutoOwnZddc(c.absPath, principalEmail)
}
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.)