ZDDC/zddc/internal/zddc/cascade.go
ZDDC 4eeb25c0ef feat(server): local-only tool-HTML override; remove apps URL/version fetching
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.

Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
   / a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
   server-side via internal/zipfs (local file, no fetch, no signature;
   re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.

Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
  spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
  SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
  cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
  walker merges, cascade-summary adds, validate.go apps validation
  (ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
  AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
  apps:/apps_pubkey: in an existing .zddc is now silently ignored
  (back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
  drops the apps/apps_pubkey keys + appsmap case.

Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
  stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
  Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
  server reads members from the filesystem internally.

Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:59:28 -05:00

457 lines
15 KiB
Go

package zddc
import (
"os"
"path/filepath"
"strings"
"sync"
)
// PolicyChain represents a chain of .zddc files from root to leaf.
//
// Embedded sits BELOW Levels[0] (the on-disk root .zddc): it's the
// baked-in defaults that ship with the binary, used as a baseline
// when an on-disk .zddc doesn't specify a rule. Consumers that want
// the full effective view should consult Levels then fall back to
// Embedded for unresolved lookups.
//
// Inherit:false on any level in Levels (or at deeper levels of a
// future paths: walker) zeroes out Embedded for that policy chain —
// the operator has taken full responsibility for spelling out every
// rule from scratch.
type PolicyChain struct {
Levels []ZddcFile // ordered root (index 0) → leaf (last index)
HasAnyFile bool // true if at least one .zddc file exists in the chain
Embedded ZddcFile // baked-in defaults; zero ZddcFile{} if any level set inherit:false
}
// VisibleStart returns the lowest level index visible to evaluation at
// any level in [0, toIdx], honoring inherit:false fences. A level with
// `acl.inherit: false` is a fence: ancestors above it are invisible to
// descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins).
//
// toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int) int {
if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1
}
if toIdx < 0 {
return 0
}
for i := toIdx; i >= 0; i-- {
if !chain.Levels[i].ACL.InheritsAncestors() {
return i
}
}
return 0
}
// EffectiveHistory reports whether edit-history versioning is enabled
// for writes at this chain's directory. Unlike DropTarget (leaf-only),
// history is a subtree behavior: the closest-to-leaf explicit setting
// wins and applies to all descendants. It deliberately IGNORES
// inherit:false ACL fences — versioning is a write behavior, not a
// permission, so a fenced per-user home under a history-enabled
// working/ still records history. Falls back to the embedded defaults.
func (chain PolicyChain) EffectiveHistory() bool {
for i := len(chain.Levels) - 1; i >= 0; i-- {
if v := chain.Levels[i].History; v != nil {
return *v
}
}
if v := chain.Embedded.History; v != nil {
return *v
}
return false
}
// EffectiveHistoryGlobs returns the basename globs selecting which files
// get text edit-history (deepest non-empty wins, then embedded defaults,
// then the built-in default ["*.md"]). Independent of EffectiveHistory:
// this says WHICH file types qualify; the bool gates whether snapshots are
// actually recorded.
func (chain PolicyChain) EffectiveHistoryGlobs() []string {
for i := len(chain.Levels) - 1; i >= 0; i-- {
if g := chain.Levels[i].HistoryGlobs; len(g) > 0 {
return g
}
}
if g := chain.Embedded.HistoryGlobs; len(g) > 0 {
return g
}
return []string{"*.md"}
}
// policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain.
var policyCache sync.Map
// EffectivePolicy returns the ACL policy chain for dirPath by reading
// all .zddc files from fsRoot down to dirPath.
//
// Child directory rules take precedence over parent rules.
// A deny at any level cannot be overridden by a parent allow.
func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
// Normalize: ensure fsRoot and dirPath use the same separator
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
// Build list of directories from root to dirPath (inclusive)
var dirs []string
if !strings.HasPrefix(dirPath, fsRoot) {
// dirPath must be under fsRoot; if not, treat it as an empty chain
return PolicyChain{}, nil
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil {
return PolicyChain{}, err
}
// Walk from root down: root, root/a, root/a/b, ...
dirs = append(dirs, fsRoot)
if rel != "." {
parts := strings.Split(rel, string(filepath.Separator))
current := fsRoot
for _, part := range parts {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
}
// Check cache for the most specific (deepest) cached entry we can reuse
cacheKey := dirPath
if cached, ok := policyCache.Load(cacheKey); ok {
return cached.(PolicyChain), nil
}
// Build policy chain: read each on-disk .zddc file.
onDisk := make([]ZddcFile, 0, len(dirs))
hasAny := false
for _, dir := range dirs {
zddcPath := filepath.Join(dir, ".zddc")
_, err := os.Stat(zddcPath)
if err == nil {
hasAny = true
parsed, perr := ParseFile(zddcPath)
if perr != nil {
onDisk = append(onDisk, ZddcFile{})
} else {
onDisk = append(onDisk, parsed)
}
} else {
onDisk = append(onDisk, ZddcFile{})
}
}
// Walk ancestor paths: trees alongside the on-disk chain. Each
// virtual source is a paths-map seeded by an ancestor's Paths
// (embedded or an on-disk level). At each segment we try to
// match the source's glob; on hit, the matched ZddcFile becomes
// a virtual contribution at THIS level, and its own Paths map
// becomes a new virtual source descending into deeper segments.
embedded := ZddcFile{}
if e, err := EmbeddedDefaults(); err == nil {
embedded = e
}
// segments[0] is the first child segment under fsRoot. dirs[0]
// is fsRoot itself, dirs[i+1] is the directory at segments[i].
var segments []string
if rel != "." {
segments = strings.Split(rel, string(filepath.Separator))
}
// Active virtual sources (paths-maps actively descending into the
// target). Seeded with the embedded defaults' Paths and the
// fsRoot/.zddc's Paths (both apply to fsRoot's children).
var virtualSources []map[string]ZddcFile
if embedded.Paths != nil {
virtualSources = append(virtualSources, embedded.Paths)
}
if onDisk[0].Paths != nil {
virtualSources = append(virtualSources, onDisk[0].Paths)
}
// inherit:false at any level along the way zeroes the embedded
// layer and drops accumulated ancestor contributions when first
// encountered. Track once.
embeddedActive := true
if onDisk[0].Inherit != nil && !*onDisk[0].Inherit {
embeddedActive = false
virtualSources = nil
if onDisk[0].Paths != nil {
// Re-seed only THIS level's paths; embedded.Paths drops.
virtualSources = append(virtualSources, onDisk[0].Paths)
}
}
// Assemble chain.Levels with virtual contributions merged in.
chain := PolicyChain{
Levels: make([]ZddcFile, len(dirs)),
HasAnyFile: hasAny,
}
chain.Levels[0] = onDisk[0] // root level has no ancestor virtual paths
for i, seg := range segments {
depth := i + 1
// Match each active virtual source against this segment.
var contributions []ZddcFile
var newSources []map[string]ZddcFile
for _, src := range virtualSources {
if match := matchGlob(src, seg); match != nil {
contributions = append(contributions, *match)
if match.Paths != nil {
newSources = append(newSources, match.Paths)
}
}
}
virtualSources = newSources
// Honor inherit:false on the on-disk level at this depth.
// Dropping ancestor sources also means dropping this level's
// inherited contributions; the level stands alone (plus its
// own Paths for descendants).
if onDisk[depth].Inherit != nil && !*onDisk[depth].Inherit {
embeddedActive = false
contributions = nil
virtualSources = nil
}
// Seed this level's own Paths as a virtual source for deeper
// segments. It applies BELOW this level, not at this level.
if onDisk[depth].Paths != nil {
virtualSources = append(virtualSources, onDisk[depth].Paths)
}
// Compose: ancestor contributions first (lowest specificity),
// then on-disk at this level (most specific) on top.
merged := ZddcFile{}
for _, c := range contributions {
merged = mergeOverlay(merged, c)
}
merged = mergeOverlay(merged, onDisk[depth])
chain.Levels[depth] = merged
}
// Layer in the embedded defaults as the bottom of the cascade
// (consulted as fallback by lookups that don't find a value in
// chain.Levels). The Paths-walking above has already threaded
// embedded.Paths through to the appropriate levels; this is the
// non-paths baseline for level-0-style lookups.
if embeddedActive {
chain.Embedded = embedded
for _, lvl := range onDisk {
if lvl.Inherit != nil && !*lvl.Inherit {
chain.Embedded = ZddcFile{}
break
}
}
}
policyCache.Store(cacheKey, chain)
return chain, nil
}
// EffectiveFieldCodes returns the merged field-code vocabulary
// visible at the leaf of this chain. Walks root → leaf, applying
// map-merge per top-level key (a leaf entry for the same code
// replaces the root entry, mirroring mergeOverlay).
//
// Embedded defaults are layered in below the on-disk root unless
// inherit:false on any level dropped them (chain.Embedded is zeroed
// in that case, so reading it as a baseline is safe either way).
func (chain PolicyChain) EffectiveFieldCodes() map[string]FieldCode {
out := map[string]FieldCode{}
for k, v := range chain.Embedded.FieldCodes {
out[k] = v
}
for _, lvl := range chain.Levels {
for k, v := range lvl.FieldCodes {
out[k] = v
}
}
return out
}
// EffectiveRecordRule returns the merged RecordRule for files whose
// basename matches a pattern in any level's Records map. Walks root
// → leaf, mergeRecordRule-combining successive matches so a
// per-folder .zddc can refine an ancestor's rule (add a lock, set a
// default) without restating everything.
//
// pattern is the most-specific pattern that matched (deepest level's
// chosen key); rule is the merged result; ok is false when no level
// declared a matching pattern.
//
// Matching at each level prefers literal-key over glob; see
// matchRecordRule.
func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRule, bool) {
merged := RecordRule{}
any := false
pattern := ""
consider := func(rules map[string]RecordRule) {
if pat, rule, hit := matchRecordRule(rules, basename); hit {
merged = mergeRecordRule(merged, rule)
pattern = pat
any = true
}
}
consider(chain.Embedded.Records)
for _, lvl := range chain.Levels {
consider(lvl.Records)
}
if !any {
return "", RecordRule{}, false
}
return pattern, merged, true
}
// SourceEntry names one cascade contribution to an EffectiveZddc
// composition. Level -1 is the embedded defaults baseline (chain.
// Embedded); levels 0+ index into chain.Levels (root→leaf). Contributed
// lists the top-level ZddcFile field names this level supplied a non-
// zero value for — used by inspection clients to answer "where does
// this value come from?" without re-walking the cascade.
type SourceEntry struct {
Level int `json:"level"`
Contributed []string `json:"contributed,omitempty"`
}
// EffectiveZddc composes the cascade into a single ZddcFile by walking
// chain.Embedded then chain.Levels[VisibleStart..] through mergeOverlay,
// and folding the cross-level Roles union (via lookupRoleMembers) into
// merged.Roles so the result reflects the same role membership the
// runtime ACL evaluator sees.
//
// Returned alongside is a per-source list of which top-level fields
// each contributing level declared. Caller maps SourceEntry.Level to a
// URL (-1 = embedded baseline; 0..len(chain.Levels)-1 = dirs along the
// walk from fsRoot to the requested directory).
//
// Returns the zero ZddcFile + nil sources when the chain is empty.
// Used by the ?effective=1 query on /.zddc — distinct from the .zddc
// file itself, which serves only what's defined at the leaf level.
func EffectiveZddc(chain PolicyChain) (ZddcFile, []SourceEntry) {
if len(chain.Levels) == 0 {
return ZddcFile{}, nil
}
sources := make([]SourceEntry, 0, len(chain.Levels)+1)
var merged ZddcFile
// Embedded baseline (skipped when an inherit:false fence dropped
// it; cascade.go zeroes chain.Embedded in that case).
if c := nonZeroZddcFields(chain.Embedded); len(c) > 0 {
merged = mergeOverlay(merged, chain.Embedded)
sources = append(sources, SourceEntry{Level: -1, Contributed: c})
}
leafIdx := len(chain.Levels) - 1
floor := chain.VisibleStart(leafIdx)
for i := floor; i <= leafIdx; i++ {
lvl := chain.Levels[i]
if c := nonZeroZddcFields(lvl); len(c) > 0 {
merged = mergeOverlay(merged, lvl)
sources = append(sources, SourceEntry{Level: i, Contributed: c})
}
}
// Roles: mergeOverlay does per-level name-keyed replacement, but
// the runtime evaluator unions members across levels via
// lookupRoleMembers (handling reset:true and the embedded
// baseline). Re-resolve every role name reachable in the visible
// chain so merged.Roles matches what ACL evaluation sees.
roleNames := collectRoleNames(chain, floor, leafIdx)
if len(roleNames) > 0 {
out := make(map[string]Role, len(roleNames))
for _, name := range roleNames {
members, defined := lookupRoleMembers(chain, leafIdx, name)
if !defined {
continue
}
out[name] = Role{Members: members}
}
merged.Roles = out
} else {
merged.Roles = nil
}
return merged, sources
}
// nonZeroZddcFields returns the names of top-level ZddcFile fields zf
// has populated. Field names match the yaml tags (so "acl" not "ACL").
// Used to populate SourceEntry.Contributed.
func nonZeroZddcFields(zf ZddcFile) []string {
var out []string
add := func(name string, cond bool) {
if cond {
out = append(out, name)
}
}
add("title", zf.Title != "")
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
add("admins", len(zf.Admins) > 0)
add("tables", len(zf.Tables) > 0)
add("display", len(zf.Display) > 0)
add("convert", zf.Convert != nil)
add("roles", len(zf.Roles) > 0)
add("created_by", zf.CreatedBy != "")
add("default_tool", zf.DefaultTool != "")
add("dir_tool", zf.DirTool != "")
add("auto_own", zf.AutoOwn != nil)
add("auto_own_fenced", zf.AutoOwnFenced != nil)
add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil)
add("party_source", zf.PartySource != "")
add("history", zf.History != nil)
add("history_globs", len(zf.HistoryGlobs) > 0)
add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "")
add("planned_review_date", zf.PlannedReviewDate != "")
add("planned_response_date", zf.PlannedResponseDate != "")
add("field_codes", len(zf.FieldCodes) > 0)
add("records", len(zf.Records) > 0)
add("paths", len(zf.Paths) > 0)
return out
}
// collectRoleNames returns every role name that has a definition in
// any visible level (or the embedded baseline). Used by EffectiveZddc
// to know which roles to resolve via lookupRoleMembers — without it
// we'd miss roles declared only at an ancestor not directly merged at
// the leaf level (since per-level mergeOverlay replaces Roles by key,
// not by union).
func collectRoleNames(chain PolicyChain, floor, leafIdx int) []string {
seen := make(map[string]struct{})
for i := floor; i <= leafIdx; i++ {
for name := range chain.Levels[i].Roles {
seen[name] = struct{}{}
}
}
for name := range chain.Embedded.Roles {
seen[name] = struct{}{}
}
if len(seen) == 0 {
return nil
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
return out
}
// InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath)
policyCache.Range(func(key, _ any) bool {
k := key.(string)
if k == dirPath || strings.HasPrefix(k, dirPath+string(filepath.Separator)) {
policyCache.Delete(key)
}
return true
})
}