From a0a3f8579be60abdb53115048714e03d48fcea63 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 09:32:15 -0500 Subject: [PATCH] feat(zddcfile): virtual .zddc body = leaf cascade level as YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- zddc/internal/handler/zddcfile.go | 179 ++++++++++++++----------- zddc/internal/handler/zddcfile_test.go | 106 +++++++++++++-- zddc/internal/zddc/file.go | 6 +- 3 files changed, 202 insertions(+), 89 deletions(-) diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go index 522a2fd..2e3787b 100644 --- a/zddc/internal/handler/zddcfile.go +++ b/zddc/internal/handler/zddcfile.go @@ -1,12 +1,13 @@ package handler import ( - "fmt" "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" @@ -35,13 +36,17 @@ func IsZddcFileRequest(urlPath string) bool { // user who can read the directory can read its .zddc. // On-disk: if /.zddc exists, its bytes are returned verbatim // with Content-Type: application/yaml. -// Virtual: if it does not exist, a synthetic body is returned with a -// cascade summary so the operator can see what rules are -// effective at this depth. The synthetic body is clearly -// marked with comments — PUT-saving its bytes back to the -// same URL (through the file API) materialises a real file. -// The virtual response sets X-ZDDC-Source: virtual so the -// client can distinguish. +// 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) @@ -103,9 +108,15 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - // No file on disk → synthetic placeholder body with a cascade - // summary so the user can see what's actually effective here. - body := renderVirtualZddc(cfg.Root, abs, chain) + // 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 @@ -113,75 +124,89 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) } -// renderVirtualZddc produces a self-describing YAML placeholder for a -// directory that has no .zddc on disk. The body is valid YAML (parses -// to an empty document) so a downstream YAML tool isn't fazed; the -// commentary lives in comments. Each ancestor's contribution is -// summarised so the reader sees exactly what's effective at this -// depth. -func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string { - var b strings.Builder - fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n") - 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") - fmt.Fprintf(&b, "# to override at this level — the save materialises a\n") - fmt.Fprintf(&b, "# real file here.\n") - fmt.Fprintf(&b, "#\n") - fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs)) +// 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] + } - // Walk the levels from root down. Each ZddcFile in chain.Levels - // corresponds to one ancestor (root, .../, ..., dirAbs). Show only - // the levels that contributed something non-empty. - dirs := chainDirs(fsRoot, dirAbs) - any := false - for i, lvl := range chain.Levels { - var levelDir string - if i < len(dirs) { - levelDir = dirs[i] - } else { - levelDir = fsRoot - } - entry := summariseLevel(lvl) - if entry == "" { - continue - } - any = true - fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s", - urlPathOf(fsRoot, levelDir), entry) + 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 ?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 } - if !any { - fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n") + + body, err := yaml.Marshal(&leaf) + if err != nil { + return "", err } - fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n") - fmt.Fprintf(&b, "{}\n") - return b.String() + b.WriteByte('\n') + b.Write(body) + return b.String(), nil } -// summariseLevel produces a comment block describing one .zddc level's -// non-empty contributions (title, acl, admins, apps, tables). Empty -// levels return "" so the caller can skip them. -func summariseLevel(lvl zddc.ZddcFile) string { - var b strings.Builder - if lvl.Title != "" { - fmt.Fprintf(&b, "# title: %q\n", lvl.Title) - } - if len(lvl.ACL.Permissions) > 0 { - fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions) - } - if len(lvl.Admins) > 0 { - fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins) - } - if len(lvl.Apps) > 0 { - fmt.Fprintf(&b, "# apps:\n") - for k, v := range lvl.Apps { - fmt.Fprintf(&b, "# %s: %s\n", k, v) - } - } - if len(lvl.Tables) > 0 { - fmt.Fprintf(&b, "# tables:\n") - for k, v := range lvl.Tables { - fmt.Fprintf(&b, "# %s: %s\n", k, v) - } - } - return b.String() +// 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 } diff --git a/zddc/internal/handler/zddcfile_test.go b/zddc/internal/handler/zddcfile_test.go index 03e14d4..d6259f0 100644 --- a/zddc/internal/handler/zddcfile_test.go +++ b/zddc/internal/handler/zddcfile_test.go @@ -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) { root := t.TempDir() - mustWrite(t, filepath.Join(root, ".zddc"), - "title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n") // Directory exists but has no .zddc. subDir := filepath.Join(root, "Project") if err := os.Mkdir(subDir, 0o755); err != nil { @@ -93,15 +103,93 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) { } body := rec.Body.String() 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, "bootstrap") { - t.Errorf("body missing root cascade summary: %q", body) + if !strings.Contains(body, "?effective=1") { + t.Errorf("body missing pointer to the composed-view query: %q", body) } - // Should parse as valid YAML (empty document or {} at the end). - if !strings.Contains(body, "{}") { - t.Errorf("body missing placeholder body: %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//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//working/: %s", want, body) + } } } diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 72304fa..c20edd1 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -125,9 +125,9 @@ type ConvertMetadata struct { // place = URL-fetched apps refused (only embedded + local-path apps // work). See zddc-server's setupApps. type ZddcFile struct { - ACL ACLRules `yaml:"acl" json:"acl"` - Admins []string `yaml:"admins" json:"admins,omitempty"` - Title string `yaml:"title" json:"title,omitempty"` + ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"` + Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`