ZDDC/zddc/internal/zddc/cascade.go
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

98 lines
2.7 KiB
Go

package zddc
import (
"os"
"path/filepath"
"strings"
"sync"
)
// PolicyChain represents a chain of .zddc files from root to leaf.
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
}
// 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 .zddc file
var chain PolicyChain
for _, dir := range dirs {
zddcPath := filepath.Join(dir, ".zddc")
// Check if .zddc file exists
_, err := os.Stat(zddcPath)
if err == nil {
// File exists
chain.HasAnyFile = true
parsed, err := ParseFile(zddcPath)
if err != nil {
// Parse error — append empty file but continue
chain.Levels = append(chain.Levels, ZddcFile{})
} else {
chain.Levels = append(chain.Levels, parsed)
}
} else if os.IsNotExist(err) {
// File doesn't exist
chain.Levels = append(chain.Levels, ZddcFile{})
} else {
// Other error (permission, etc.)
chain.Levels = append(chain.Levels, ZddcFile{})
}
}
policyCache.Store(cacheKey, chain)
return chain, nil
}
// 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
})
}