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:
parent
43c2879e9c
commit
a0a3f8579b
3 changed files with 202 additions and 89 deletions
|
|
@ -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 <dir>/.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
|
||||
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
|
||||
}
|
||||
entry := summariseLevel(lvl)
|
||||
if entry == "" {
|
||||
continue
|
||||
|
||||
body, err := yaml.Marshal(&leaf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
any = true
|
||||
fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s",
|
||||
urlPathOf(fsRoot, levelDir), entry)
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue