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>
This commit is contained in:
ZDDC 2026-05-21 09:32:15 -05:00
parent 43c2879e9c
commit a0a3f8579b
3 changed files with 202 additions and 89 deletions

View file

@ -1,12 +1,13 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -35,13 +36,17 @@ func IsZddcFileRequest(urlPath string) bool {
// user who can read the directory can read its .zddc. // user who can read the directory can read its .zddc.
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim // On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
// with Content-Type: application/yaml. // with Content-Type: application/yaml.
// Virtual: if it does not exist, a synthetic body is returned with a // Virtual: if it does not exist, the body is the cascade's
// cascade summary so the operator can see what rules are // leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// effective at this depth. The synthetic body is clearly // tree declares for THIS exact directory, plus any
// marked with comments — PUT-saving its bytes back to the // virtual contributions threaded through by the walker)
// same URL (through the file API) materialises a real file. // marshalled as YAML. A header comment names the source
// The virtual response sets X-ZDDC-Source: virtual so the // and points at ?effective=1 for the composed view. The
// client can distinguish. // 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) { func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
decider := DeciderFromContext(r) decider := DeciderFromContext(r)
@ -103,9 +108,15 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// No file on disk → synthetic placeholder body with a cascade // No file on disk → render the cascade's leaf level as YAML.
// summary so the user can see what's actually effective here. // What the user sees is the embedded defaults' declared shape
body := renderVirtualZddc(cfg.Root, abs, chain) // 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") w.Header().Set("X-ZDDC-Source", "virtual:zddc")
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
return return
@ -113,75 +124,89 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body)) _, _ = w.Write([]byte(body))
} }
// renderVirtualZddc produces a self-describing YAML placeholder for a // renderVirtualZddc produces a YAML body for a directory that has no
// directory that has no .zddc on disk. The body is valid YAML (parses // .zddc on disk. The body is the cascade's leaf-level ZddcFile —
// to an empty document) so a downstream YAML tool isn't fazed; the // i.e. what defaults.zddc.yaml's paths: tree declares for this exact
// commentary lives in comments. Each ancestor's contribution is // directory, plus any contributions the walker threaded through. The
// summarised so the reader sees exactly what's effective at this // goal is to expose the embedded defaults' source of truth: a new
// depth. // user opening the virtual .zddc here sees, in the same yaml shape
func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string { // they would write themselves, what behavior is currently declared
var b strings.Builder // at this level. A header comment names the source and points at
fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n") // ?effective=1 for the composed view across the chain.
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. Edit + save\n") //
fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n") // PUT-saving these bytes back through the file API materialises a
fmt.Fprintf(&b, "# to override at this level — the save materialises a\n") // real on-disk override carrying exactly the saved content — the
fmt.Fprintf(&b, "# real file here.\n") // virtual body is a template, not a contract; the operator can
fmt.Fprintf(&b, "#\n") // trim / extend / overwrite freely.
fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs)) func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
var leaf zddc.ZddcFile
if n := len(chain.Levels); n > 0 {
leaf = chain.Levels[n-1]
}
// Walk the levels from root down. Each ZddcFile in chain.Levels var b strings.Builder
// corresponds to one ancestor (root, .../, ..., dirAbs). Show only b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
// the levels that contributed something non-empty. b.WriteString("# The content below is what the embedded defaults\n")
dirs := chainDirs(fsRoot, dirAbs) b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
any := false b.WriteString("# exact path. Edit and save through the YAML editor in\n")
for i, lvl := range chain.Levels { b.WriteString("# browse to materialise a real .zddc here carrying your\n")
var levelDir string b.WriteString("# changes; the bytes you save become the override\n")
if i < len(dirs) { b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
levelDir = dirs[i] b.WriteString("# policy and are the single source of truth).\n")
} else { b.WriteString("#\n")
levelDir = fsRoot 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
} }
entry := summariseLevel(lvl)
if entry == "" { body, err := yaml.Marshal(&leaf)
continue if err != nil {
return "", err
} }
any = true b.WriteByte('\n')
fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s", b.Write(body)
urlPathOf(fsRoot, levelDir), entry) return b.String(), nil
}
if !any {
fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n")
}
fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n")
fmt.Fprintf(&b, "{}\n")
return b.String()
} }
// summariseLevel produces a comment block describing one .zddc level's // isZeroZddcFile reports whether zf carries no declarations a user
// non-empty contributions (title, acl, admins, apps, tables). Empty // would want to see — every field is its zero value. Used to switch
// levels return "" so the caller can skip them. // the virtual body between the rich path (marshal the leaf) and the
func summariseLevel(lvl zddc.ZddcFile) string { // empty-placeholder path (just say "nothing declared here").
var b strings.Builder //
if lvl.Title != "" { // The ACL substruct's Inherit pointer being nil is part of "zero"
fmt.Fprintf(&b, "# title: %q\n", lvl.Title) // here; an explicit inherit: false is itself a declaration worth
} // surfacing.
if len(lvl.ACL.Permissions) > 0 { func isZeroZddcFile(zf zddc.ZddcFile) bool {
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions) return zf.Title == "" &&
} zf.AppsPubKey == "" &&
if len(lvl.Admins) > 0 { zf.CreatedBy == "" &&
fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins) zf.DefaultTool == "" &&
} zf.DirTool == "" &&
if len(lvl.Apps) > 0 { zf.ReceivedPath == "" &&
fmt.Fprintf(&b, "# apps:\n") zf.PlannedReviewDate == "" &&
for k, v := range lvl.Apps { zf.PlannedResponseDate == "" &&
fmt.Fprintf(&b, "# %s: %s\n", k, v) zf.ACL.Inherit == nil &&
} zf.AutoOwn == nil &&
} zf.AutoOwnFenced == nil &&
if len(lvl.Tables) > 0 { zf.Virtual == nil &&
fmt.Fprintf(&b, "# tables:\n") zf.DropTarget == nil &&
for k, v := range lvl.Tables { zf.Convert == nil &&
fmt.Fprintf(&b, "# %s: %s\n", k, v) len(zf.ACL.Permissions) == 0 &&
} len(zf.Admins) == 0 &&
} len(zf.Apps) == 0 &&
return b.String() 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
} }

View file

@ -68,10 +68,20 @@ func TestServeZddcFile_ExistingFile(t *testing.T) {
} }
} }
// TestServeZddcFile_VirtualDefault — when no .zddc is on disk at the
// requested directory, the body is the cascade's leaf-level ZddcFile
// marshalled as YAML, prefixed by a header comment explaining what
// the file is and pointing at ?effective=1 for the composed view.
//
// At /Project/.zddc with no on-disk file, the leaf is the embedded
// defaults' paths.* contribution — i.e. the project-scoped baseline
// (project_team: r, observer: r, document_controller: rw) plus the
// canonical paths: tree (archive, working, staging, reviewing, …).
// Asserts a few load-bearing markers; the full content is the
// `defaults.zddc.yaml` source-of-truth, which lives under
// zddc/internal/zddc and is parsed at every cascade walk.
func TestServeZddcFile_VirtualDefault(t *testing.T) { func TestServeZddcFile_VirtualDefault(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n")
// Directory exists but has no .zddc. // Directory exists but has no .zddc.
subDir := filepath.Join(root, "Project") subDir := filepath.Join(root, "Project")
if err := os.Mkdir(subDir, 0o755); err != nil { if err := os.Mkdir(subDir, 0o755); err != nil {
@ -93,15 +103,93 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) {
} }
body := rec.Body.String() body := rec.Body.String()
if !strings.Contains(body, "Virtual .zddc") { if !strings.Contains(body, "Virtual .zddc") {
t.Errorf("body missing virtual marker: %q", body) t.Errorf("body missing virtual header comment: %q", body)
} }
// Should show the root's title from the cascade. if !strings.Contains(body, "?effective=1") {
if !strings.Contains(body, "bootstrap") { t.Errorf("body missing pointer to the composed-view query: %q", body)
t.Errorf("body missing root cascade summary: %q", body) }
// The embedded defaults declare project_team: r and
// observer: r at paths.*. Confirm both surface so the user
// sees the project-scoped baseline.
if !strings.Contains(body, "project_team: r") {
t.Errorf("body missing project_team grant from embedded defaults: %q", body)
}
if !strings.Contains(body, "observer: r") {
t.Errorf("body missing observer grant from embedded defaults: %q", body)
}
// The paths: subtree below should include archive (the only
// physical project-root child) and the virtual aggregators.
if !strings.Contains(body, "archive:") {
t.Errorf("body missing archive subtree: %q", body)
}
}
// TestServeZddcFile_VirtualEmpty — at a directory the embedded
// defaults' paths: tree does NOT cover, the body collapses to the
// header comment + an empty-document placeholder ({}). The user
// sees "no rules declared at this exact level".
func TestServeZddcFile_VirtualEmpty(t *testing.T) {
root := t.TempDir()
// /Project/random-subfolder/ is not declared in the embedded
// defaults' paths tree (paths.* matches the project name, but
// no child path matches "random-subfolder").
deep := filepath.Join(root, "Project", "random-subfolder")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/random-subfolder/.zddc", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Virtual .zddc") {
t.Errorf("body missing virtual header: %q", body)
}
if !strings.Contains(body, "{}") {
t.Errorf("undeclared-level body should end in {}: %q", body)
}
if !strings.Contains(body, "inherited from ancestors") {
t.Errorf("undeclared-level body should explain inheritance: %q", body)
}
}
// TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared
// by the embedded defaults (archive/<party>/working/) shows its own
// rich subtree: default_tool, available_tools, auto_own, etc.
func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
root := t.TempDir()
deep := filepath.Join(root, "Project", "archive", "Acme", "working")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/working/.zddc", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"default_tool: browse", // working/ default_tool
"auto_own: true", // working/ creator owns subdirs
"drop_target: true", // upload zone
"classifier", // available_tools includes classifier
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q at archive/<party>/working/: %s", want, body)
} }
// Should parse as valid YAML (empty document or {} at the end).
if !strings.Contains(body, "{}") {
t.Errorf("body missing placeholder body: %q", body)
} }
} }

View file

@ -125,9 +125,9 @@ type ConvertMetadata struct {
// place = URL-fetched apps refused (only embedded + local-path apps // place = URL-fetched apps refused (only embedded + local-path apps
// work). See zddc-server's setupApps. // work). See zddc-server's setupApps.
type ZddcFile struct { type ZddcFile struct {
ACL ACLRules `yaml:"acl" json:"acl"` ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins" json:"admins,omitempty"` Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title" json:"title,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"` AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`