ZDDC/zddc/internal/handler/zddcfile.go
ZDDC a0a3f8579b feat(zddcfile): virtual .zddc body = leaf cascade level as YAML
When no .zddc is on disk at the requested directory, ServeZddcFile
now renders the cascade's leaf-level ZddcFile as YAML — what
defaults.zddc.yaml's paths: tree declares for THIS exact path,
threaded through by the walker. The previous body was a comment-
only summary plus a `{}` placeholder, which forced operators to
write any override from scratch.

The .zddc file is still the single source of truth — no synthesis,
no merge: the virtual body IS the embedded subtree, marshalled in
the same shape the operator would write themselves. PUT-saving the
bytes back through the file API materialises an on-disk override
carrying exactly what the user saved. For the COMPOSED view across
the full chain, slice 2 will add ?effective=1 (returns JSON, not a
.zddc); the header comment in the virtual body points at it.

Three new test cases lock the contract:
  - VirtualDefault: at /Project/.zddc with no on-disk file, the
    embedded paths.* contribution surfaces (project_team: r,
    observer: r, archive subtree, …).
  - VirtualEmpty: at a path the embedded defaults don't declare
    (e.g. /Project/random-subfolder/.zddc), the body collapses to
    the header + an empty-document {} placeholder + an explanation
    that rules come from ancestors only.
  - VirtualPerPartyWorking: at /Project/archive/Acme/working/.zddc,
    the body carries default_tool/auto_own/drop_target and the
    classifier in available_tools — the per-party in-flight slot's
    full declaration.

Drive-by: add `omitempty` to ZddcFile.ACL, .Admins, .Title yaml
tags. Without it, the marshaled virtual body carried `acl: {}`,
`admins: []`, and `title: ""` at every nested level, drowning the
real content in noise. ParseFile is unaffected (input parsing
ignores omitempty); WriteFile's round-trip sanity check still
passes.

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

212 lines
7.8 KiB
Go

package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ZddcFileBasename is the leaf the dispatcher recognises as a raw
// .zddc YAML view request. Carved out of the dot-prefix guard so any
// directory's .zddc is reachable at <dir>/.zddc — without it, the
// dispatcher 404s anything beginning with a dot.
const ZddcFileBasename = ".zddc"
// IsZddcFileRequest reports whether urlPath ends with the raw .zddc
// leaf. Used by the dispatcher to route a GET/HEAD to ServeZddcFile
// before the dot-prefix guard rejects it.
func IsZddcFileRequest(urlPath string) bool {
clean := strings.TrimSuffix(urlPath, "/")
return strings.HasSuffix(clean, "/"+ZddcFileBasename) ||
clean == "/"+ZddcFileBasename
}
// ServeZddcFile serves a directory's .zddc as a plain YAML view.
//
// Method: GET / HEAD only — the dispatcher routes writes
// (PUT/DELETE/POST) directly to ServeFileAPI.
// ACL: the parent directory's read permission gates access. A
// user who can read the directory can read its .zddc.
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
// with Content-Type: application/yaml.
// Virtual: if it does not exist, the body is the cascade's
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// tree declares for THIS exact directory, plus any
// virtual contributions threaded through by the walker)
// marshalled as YAML. A header comment names the source
// and points at ?effective=1 for the composed view. The
// virtual body is itself valid YAML — PUT-saving it back
// (with or without edits) through the file API
// materialises a real on-disk override carrying exactly
// the bytes the user saved. The response sets
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
decider := DeciderFromContext(r)
// URL is <dir>/.zddc. Strip the leaf to get the directory.
urlPath := r.URL.Path
leaf := "/" + ZddcFileBasename
if !strings.HasSuffix(urlPath, leaf) {
http.NotFound(w, r)
return
}
dirURL := strings.TrimSuffix(urlPath, leaf)
if dirURL == "" {
dirURL = "/"
}
// Translate the URL into an absolute filesystem path. The parent
// directory must exist on disk (with one exception: the root
// itself, which always exists). We do NOT require the directory
// to exist if it's a canonical virtual folder — the cascade is
// still defined for those paths via the ancestors.
rel := strings.Trim(dirURL, "/")
abs := cfg.Root
if rel != "" {
abs = filepath.Join(cfg.Root, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) {
http.NotFound(w, r)
return
}
}
// ACL gate: read permission on the parent directory. We resolve
// against the directory's effective policy chain, not the .zddc
// file's own permissions (the file isn't a separate ACL target —
// it's the source of the rules themselves).
chain, err := zddc.EffectivePolicy(cfg.Root, abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if allowed, _ := policy.AllowFromChainP(r.Context(), decider, chain, PrincipalFromContext(r), dirURL); !allowed {
http.NotFound(w, r) // hide existence from unauthorised callers
return
}
zddcPath := filepath.Join(abs, ".zddc")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
// On-disk file: serve bytes verbatim.
if data, err := os.ReadFile(zddcPath); err == nil {
w.Header().Set("X-ZDDC-Source", "file:"+filepath.ToSlash(strings.TrimPrefix(zddcPath, cfg.Root)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(data)
return
} else if !os.IsNotExist(err) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// No file on disk → render the cascade's leaf level as YAML.
// What the user sees is the embedded defaults' declared shape
// for this exact path; PUT-saving it back materialises an
// on-disk override verbatim.
body, err := renderVirtualZddc(chain)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("X-ZDDC-Source", "virtual:zddc")
if r.Method == http.MethodHead {
return
}
_, _ = w.Write([]byte(body))
}
// renderVirtualZddc produces a YAML body for a directory that has no
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
// i.e. what defaults.zddc.yaml's paths: tree declares for this exact
// directory, plus any contributions the walker threaded through. The
// goal is to expose the embedded defaults' source of truth: a new
// user opening the virtual .zddc here sees, in the same yaml shape
// they would write themselves, what behavior is currently declared
// at this level. A header comment names the source and points at
// ?effective=1 for the composed view across the chain.
//
// PUT-saving these bytes back through the file API materialises a
// real on-disk override carrying exactly the saved content — the
// virtual body is a template, not a contract; the operator can
// trim / extend / overwrite freely.
func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
var leaf zddc.ZddcFile
if n := len(chain.Levels); n > 0 {
leaf = chain.Levels[n-1]
}
var b strings.Builder
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
b.WriteString("# The content below is what the embedded defaults\n")
b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
b.WriteString("# changes; the bytes you save become the override\n")
b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
b.WriteString("# policy and are the single source of truth).\n")
b.WriteString("#\n")
b.WriteString("# For the COMPOSED effective config across the whole\n")
b.WriteString("# cascade (all ancestors merged), query:\n")
b.WriteString("# GET <this-url>?effective=1 (JSON, not a .zddc).\n")
if isZeroZddcFile(leaf) {
b.WriteString("#\n")
b.WriteString("# No rules declared at this exact level — every rule\n")
b.WriteString("# currently in effect here is inherited from ancestors.\n")
b.WriteString("{}\n")
return b.String(), nil
}
body, err := yaml.Marshal(&leaf)
if err != nil {
return "", err
}
b.WriteByte('\n')
b.Write(body)
return b.String(), nil
}
// isZeroZddcFile reports whether zf carries no declarations a user
// would want to see — every field is its zero value. Used to switch
// the virtual body between the rich path (marshal the leaf) and the
// empty-placeholder path (just say "nothing declared here").
//
// The ACL substruct's Inherit pointer being nil is part of "zero"
// here; an explicit inherit: false is itself a declaration worth
// surfacing.
func isZeroZddcFile(zf zddc.ZddcFile) bool {
return zf.Title == "" &&
zf.AppsPubKey == "" &&
zf.CreatedBy == "" &&
zf.DefaultTool == "" &&
zf.DirTool == "" &&
zf.ReceivedPath == "" &&
zf.PlannedReviewDate == "" &&
zf.PlannedResponseDate == "" &&
zf.ACL.Inherit == nil &&
zf.AutoOwn == nil &&
zf.AutoOwnFenced == nil &&
zf.Virtual == nil &&
zf.DropTarget == nil &&
zf.Convert == nil &&
len(zf.ACL.Permissions) == 0 &&
len(zf.Admins) == 0 &&
len(zf.Apps) == 0 &&
len(zf.Tables) == 0 &&
len(zf.Display) == 0 &&
len(zf.Roles) == 0 &&
len(zf.FieldCodes) == 0 &&
len(zf.Records) == 0 &&
len(zf.AvailableTools) == 0 &&
len(zf.Worm) == 0 &&
len(zf.Paths) == 0
}