diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go
index cd2bb78..1e32bbe 100644
--- a/zddc/internal/zddc/cascade.go
+++ b/zddc/internal/zddc/cascade.go
@@ -1,6 +1,7 @@
package zddc
import (
+ "archive/zip"
"os"
"path/filepath"
"strings"
@@ -126,23 +127,27 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
return cached.(PolicyChain), nil
}
- // Build policy chain: read each on-disk .zddc file.
+ // Build policy chain: read each level's on-disk policy. A level's
+ // contribution is an optional .zddc.zip policy bundle mounted here (a whole
+ // subtree: its own-level member at this level, its deeper members threaded
+ // to descendants via Paths) with the plain
/.zddc overlaid on top
+ // (most-specific human edit wins). Either, both, or neither may be present.
onDisk := make([]ZddcFile, 0, len(dirs))
hasAny := false
for _, dir := range dirs {
- zddcPath := filepath.Join(dir, ".zddc")
- _, err := os.Stat(zddcPath)
- if err == nil {
+ level := ZddcFile{}
+ if zipZf, ok := zipPolicyAt(dir); ok {
hasAny = true
- parsed, perr := ParseFile(zddcPath)
- if perr != nil {
- onDisk = append(onDisk, ZddcFile{})
- } else {
- onDisk = append(onDisk, parsed)
- }
- } else {
- onDisk = append(onDisk, ZddcFile{})
+ level = zipZf
}
+ zddcPath := filepath.Join(dir, ".zddc")
+ if _, err := os.Stat(zddcPath); err == nil {
+ hasAny = true
+ if parsed, perr := ParseFile(zddcPath); perr == nil {
+ level = mergeOverlay(level, parsed)
+ }
+ }
+ onDisk = append(onDisk, level)
}
// Walk ancestor paths: trees alongside the on-disk chain. Each
@@ -254,6 +259,31 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
return chain, nil
}
+// zipPolicyAt loads an operator policy bundle at /.zddc.zip and assembles
+// it into a single nested ZddcFile (its own-level content + Paths threading its
+// deeper members to descendants), or (_, false) when the bundle is absent,
+// unreadable, or carries no .zddc members (e.g. a tool-HTML-only bundle — those
+// are ignored for policy). Mounting the bundle at dir contributes a policy
+// subtree there; inherit:false in its resolved .zddc makes that subtree a
+// self-contained island. Member paths use "*" for the any-segment wildcard,
+// resolved by the same literal-first matching as paths:.
+func zipPolicyAt(dir string) (ZddcFile, bool) {
+ zipPath := filepath.Join(dir, ".zddc.zip")
+ if fi, err := os.Stat(zipPath); err != nil || fi.IsDir() {
+ return ZddcFile{}, false
+ }
+ zr, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return ZddcFile{}, false
+ }
+ defer zr.Close()
+ tree, err := LoadPolicyTreeFromFS(zr, ".")
+ if err != nil || len(tree) == 0 {
+ return ZddcFile{}, false
+ }
+ return tree.Assemble(), true
+}
+
// EffectiveFieldCodes returns the merged field-code vocabulary
// visible at the leaf of this chain. Walks root → leaf, applying
// map-merge per top-level key (a leaf entry for the same code
diff --git a/zddc/internal/zddc/cascade_zip_test.go b/zddc/internal/zddc/cascade_zip_test.go
new file mode 100644
index 0000000..27ec09b
--- /dev/null
+++ b/zddc/internal/zddc/cascade_zip_test.go
@@ -0,0 +1,85 @@
+package zddc
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeTestZip(t *testing.T, zipPath string, members map[string]string) {
+ t.Helper()
+ f, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ zw := zip.NewWriter(f)
+ for name, body := range members {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write([]byte(body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A .zddc.zip dropped at any directory mounts a policy subtree there: its
+// own-level member governs that directory, its "*"/named members govern
+// descendants, and inherit:false makes it a self-contained island that ignores
+// the ancestor cascade + embedded site defaults.
+func TestZddcZipMountedAtSubtree(t *testing.T) {
+ root := t.TempDir()
+ // Site root: project_team has a member; embedded defaults apply below.
+ if err := os.WriteFile(filepath.Join(root, ".zddc"),
+ []byte("roles:\n project_team:\n members: [team@x]\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range []string{"Proj/special", "Proj/normal"} {
+ if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Drop a self-contained island at /Proj/special (inherit:false) granting
+ // only *@vendor.com, with a "*" descendant rule (read-only below).
+ // A complete island fences both layers: top-level inherit:false drops the
+ // embedded defaults + ancestor paths: contributions, and acl.inherit:false
+ // clamps the ACL level-walk so ancestor levels' grants don't leak in.
+ writeTestZip(t, filepath.Join(root, "Proj", "special", ".zddc.zip"), map[string]string{
+ ".zddc": "inherit: false\nacl:\n inherit: false\n permissions:\n \"*@vendor.com\": rwcd\n",
+ "*/.zddc": "acl:\n permissions:\n \"*@vendor.com\": r\n",
+ })
+ InvalidateCache(root)
+
+ verbs := func(dir, email string) VerbSet {
+ chain, err := EffectivePolicy(root, filepath.Join(root, filepath.FromSlash(dir)))
+ if err != nil {
+ t.Fatalf("EffectivePolicy %s: %v", dir, err)
+ }
+ return EffectiveVerbs(chain, email)
+ }
+
+ // Bundle root member governs /Proj/special.
+ if v := verbs("Proj/special", "u@vendor.com"); !v.Has(VerbC) || !v.Has(VerbW) || !v.Has(VerbD) {
+ t.Errorf("/Proj/special vendor verbs = %v, want rwcd", v)
+ }
+ // Bundle's */.zddc governs a (virtual) descendant — read-only, deepest-wins.
+ if v := verbs("Proj/special/anychild", "u@vendor.com"); !v.Has(VerbR) || v.Has(VerbW) {
+ t.Errorf("/Proj/special/anychild vendor verbs = %v, want r only", v)
+ }
+ // inherit:false fences the site defaults: the embedded project-level
+ // project_team grant has NO effect inside the island.
+ if v := verbs("Proj/special", "team@x"); v != 0 {
+ t.Errorf("/Proj/special team verbs = %v, want none (fenced island)", v)
+ }
+ // Outside the island, the embedded project-level grant still applies.
+ if v := verbs("Proj/normal", "team@x"); !v.Has(VerbR) {
+ t.Errorf("/Proj/normal team verbs = %v, want r (embedded project_team:r)", v)
+ }
+}