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"`