feat(zddcfile): ?effective=1 composed-cascade inspection query
Add GET /<path>/.zddc?effective=1 returning JSON with the composed
ZddcFile across the full cascade plus a per-level source list. The
.zddc file itself still serves only what's defined at that level
(YAML, the source of truth); the new query is inspection-only
(JSON, never written back). The virtual .zddc body's header
comment already pointed at this URL — now it's live.
Wire shape:
{ url_path: "/Project-1/archive/Acme/working/",
merged: { …ZddcFile JSON, composed view… },
sources: [ { level: -1, url: "<embedded>",
contributed: ["roles", "available_tools", "paths"] },
{ level: 0, url: "/.zddc",
contributed: ["acl", "admins"] },
{ level: 4, url: "/Project-1/archive/Acme/working/.zddc",
contributed: ["default_tool", "auto_own", …] } ] }
New zddc.EffectiveZddc(chain) walks chain.Embedded then
chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the
cross-level Roles union (via the existing lookupRoleMembers,
matching the runtime ACL evaluator's semantics). Returns
([]SourceEntry) listing each contributing level with its non-zero
top-level fields. The handler maps SourceEntry.Level to a directory
URL: -1 → "<embedded>"; 0..n → "/<seg/seg/.../>.zddc".
ACL gate is the same as the YAML view (read on the directory).
X-ZDDC-Source: virtual:effective so clients can distinguish.
Four tests cover the contract:
- BasicCompose: alice's root grant + project_team baseline from
embedded + the project's title all surface in merged; sources
include -1 (embedded), 0 (root), 1 (project).
- InheritFence: top-level inherit:false on /Closed/.zddc drops
every ancestor including the embedded baseline from sources.
- RoleMemberUnion: document_controller declared at root and
project unions members in merged.roles (matches the runtime
cross-level union the ACL evaluator performs).
- existing virtual-body tests still pass — they hit the YAML path,
not the JSON branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0a3f8579b
commit
b5a725e745
3 changed files with 405 additions and 0 deletions
|
|
@ -91,6 +91,16 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// ?effective=1 branch: return the composed cascade view as JSON.
|
||||
// Distinct from the .zddc file itself — the YAML body is "what's
|
||||
// defined at this level" (source of truth); this is "what's
|
||||
// effective after merging every ancestor" (inspection only, not
|
||||
// PUT-saveable as a .zddc).
|
||||
if r.URL.Query().Get("effective") == "1" {
|
||||
serveEffectiveZddc(cfg, dirURL, chain, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
zddcPath := filepath.Join(abs, ".zddc")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
|
|
@ -175,6 +185,85 @@ func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
|
|||
return b.String(), nil
|
||||
}
|
||||
|
||||
// effectiveSourceView is the wire shape for one entry in the
|
||||
// `sources` array of the ?effective=1 response. Level matches
|
||||
// zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index);
|
||||
// URL is the directory URL of that level (or "<embedded>" for the
|
||||
// baseline); Contributed lists the top-level fields the level
|
||||
// declared.
|
||||
type effectiveSourceView struct {
|
||||
Level int `json:"level"`
|
||||
URL string `json:"url"`
|
||||
Contributed []string `json:"contributed,omitempty"`
|
||||
}
|
||||
|
||||
// effectiveZddcView is the wire shape for the ?effective=1 response.
|
||||
// Merged is the composed cascade as a ZddcFile (same struct shape the
|
||||
// editor consumes for an on-disk .zddc; client-side renderers can
|
||||
// reuse the same parser). Sources lists per-level contributions so
|
||||
// the user can trace any value back to its origin without re-walking
|
||||
// the cascade by hand.
|
||||
type effectiveZddcView struct {
|
||||
URLPath string `json:"url_path"`
|
||||
Merged zddc.ZddcFile `json:"merged"`
|
||||
Sources []effectiveSourceView `json:"sources"`
|
||||
}
|
||||
|
||||
// serveEffectiveZddc writes the JSON composed-cascade view for the
|
||||
// .zddc URL. Same ACL as the YAML view (already enforced by the
|
||||
// caller).
|
||||
func serveEffectiveZddc(cfg config.Config, dirURL string, chain zddc.PolicyChain, w http.ResponseWriter, r *http.Request) {
|
||||
merged, sources := zddc.EffectiveZddc(chain)
|
||||
levelURLs := levelURLsFor(cfg.Root, dirURL, len(chain.Levels))
|
||||
view := effectiveZddcView{
|
||||
URLPath: dirURL,
|
||||
Merged: merged,
|
||||
Sources: make([]effectiveSourceView, 0, len(sources)),
|
||||
}
|
||||
for _, s := range sources {
|
||||
entry := effectiveSourceView{Level: s.Level, Contributed: s.Contributed}
|
||||
if s.Level < 0 {
|
||||
entry.URL = "<embedded>"
|
||||
} else if s.Level < len(levelURLs) {
|
||||
entry.URL = levelURLs[s.Level] + ".zddc"
|
||||
}
|
||||
view.Sources = append(view.Sources, entry)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-ZDDC-Source", "virtual:effective")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
writeJSON(w, view)
|
||||
}
|
||||
|
||||
// levelURLsFor maps each chain level index to its directory URL. The
|
||||
// chain walks dirs root→leaf so levelURLs[0] = "/", levelURLs[1] is
|
||||
// the first segment, etc. Length must equal len(chain.Levels).
|
||||
//
|
||||
// Used by serveEffectiveZddc to populate SourceEntry.URL — clients
|
||||
// receive concrete .zddc URLs they can navigate to rather than bare
|
||||
// integer indices.
|
||||
func levelURLsFor(_, dirURL string, n int) []string {
|
||||
dirURL = strings.TrimSuffix(dirURL, "/")
|
||||
out := make([]string, n)
|
||||
out[0] = "/"
|
||||
if dirURL == "" || n == 1 {
|
||||
return out
|
||||
}
|
||||
segs := strings.Split(strings.TrimPrefix(dirURL, "/"), "/")
|
||||
cur := ""
|
||||
for i, seg := range segs {
|
||||
if i+1 >= n {
|
||||
break
|
||||
}
|
||||
cur += "/" + seg
|
||||
out[i+1] = cur + "/"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
|
@ -160,6 +161,186 @@ func TestServeZddcFile_VirtualEmpty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestServeZddcFile_Effective_BasicCompose — ?effective=1 returns the
|
||||
// merged composed view across embedded baseline + on-disk levels.
|
||||
// Body is JSON with the merged ZddcFile and per-level source list.
|
||||
func TestServeZddcFile_Effective_BasicCompose(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
|
||||
proj := filepath.Join(root, "Project")
|
||||
if err := os.Mkdir(proj, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(proj, ".zddc"),
|
||||
"title: My Project\n")
|
||||
zddc.InvalidateCache(root)
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", 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())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:effective" {
|
||||
t.Errorf("X-ZDDC-Source = %q, want virtual:effective", got)
|
||||
}
|
||||
|
||||
var view effectiveZddcView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v (body: %s)", err, rec.Body.String())
|
||||
}
|
||||
if view.URLPath != "/Project" {
|
||||
t.Errorf("url_path = %q, want /Project", view.URLPath)
|
||||
}
|
||||
// Merged should carry alice's grant (from root) AND the title
|
||||
// from /Project, AND the project_team grant from the embedded
|
||||
// defaults' paths.* contribution.
|
||||
if view.Merged.ACL.Permissions["alice@example.com"] != "rwcda" {
|
||||
t.Errorf("merged.acl.permissions missing alice's grant: %+v", view.Merged.ACL.Permissions)
|
||||
}
|
||||
if view.Merged.ACL.Permissions["project_team"] != "r" {
|
||||
t.Errorf("merged.acl.permissions missing project_team (from embedded defaults paths.*): %+v", view.Merged.ACL.Permissions)
|
||||
}
|
||||
if view.Merged.Title != "My Project" {
|
||||
t.Errorf("merged.title = %q, want My Project (from /Project/.zddc)", view.Merged.Title)
|
||||
}
|
||||
// Sources should include the embedded baseline (level -1) and
|
||||
// the two on-disk levels.
|
||||
var levels []int
|
||||
for _, s := range view.Sources {
|
||||
levels = append(levels, s.Level)
|
||||
}
|
||||
wantLevels := map[int]bool{-1: true, 0: true, 1: true}
|
||||
for _, l := range levels {
|
||||
delete(wantLevels, l)
|
||||
}
|
||||
if len(wantLevels) > 0 {
|
||||
t.Errorf("missing source levels %v in %v", wantLevels, levels)
|
||||
}
|
||||
// Per-level URLs are populated.
|
||||
for _, s := range view.Sources {
|
||||
if s.Level == -1 && s.URL != "<embedded>" {
|
||||
t.Errorf("embedded source url = %q, want <embedded>", s.URL)
|
||||
}
|
||||
if s.Level == 0 && s.URL != "/.zddc" {
|
||||
t.Errorf("root source url = %q, want /.zddc", s.URL)
|
||||
}
|
||||
if s.Level == 1 && s.URL != "/Project/.zddc" {
|
||||
t.Errorf("project source url = %q, want /Project/.zddc", s.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeZddcFile_Effective_InheritFence — inherit:false at a level
|
||||
// drops every ancestor (including the embedded baseline) from the
|
||||
// composed view. Only the fence-and-below contribute.
|
||||
func TestServeZddcFile_Effective_InheritFence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
|
||||
proj := filepath.Join(root, "Closed")
|
||||
if err := os.Mkdir(proj, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// inherit:false on Closed/.zddc — root + embedded both drop
|
||||
// out of the visible chain.
|
||||
// Top-level inherit:false drops EVERY ancestor including the
|
||||
// embedded baseline. (ACL.inherit:false would only fence ACL
|
||||
// evaluation — roles, paths-tree, and embedded baseline still
|
||||
// cascade through, which is a separate test.)
|
||||
mustWrite(t, filepath.Join(proj, ".zddc"),
|
||||
"inherit: false\n"+
|
||||
"acl:\n inherit: false\n permissions:\n \"bob@example.com\": rwcda\n")
|
||||
zddc.InvalidateCache(root)
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
// Bob has the only grant inside the fence; alice's root grant
|
||||
// is hidden by inherit:false so she'd 404 on the read gate.
|
||||
req := httptest.NewRequest(http.MethodGet, "/Closed/.zddc?effective=1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "bob@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())
|
||||
}
|
||||
var view effectiveZddcView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Alice's root grant must be invisible behind the fence.
|
||||
if _, ok := view.Merged.ACL.Permissions["alice@example.com"]; ok {
|
||||
t.Errorf("alice's root grant should be hidden by fence; got %+v", view.Merged.ACL.Permissions)
|
||||
}
|
||||
// Bob's grant at Closed/ is visible.
|
||||
if view.Merged.ACL.Permissions["bob@example.com"] != "rwcda" {
|
||||
t.Errorf("bob's fence-level grant missing: %+v", view.Merged.ACL.Permissions)
|
||||
}
|
||||
// Embedded baseline (level -1) must not appear in sources — the
|
||||
// fence zeroed it.
|
||||
for _, s := range view.Sources {
|
||||
if s.Level == -1 {
|
||||
t.Errorf("embedded baseline leaked past inherit:false fence: %+v", s)
|
||||
}
|
||||
if s.Level == 0 {
|
||||
t.Errorf("root /.zddc leaked past inherit:false fence: %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeZddcFile_Effective_RoleMemberUnion — roles defined at
|
||||
// multiple levels show the union of members (the runtime ACL
|
||||
// evaluator uses lookupRoleMembers' union, and the composed view
|
||||
// must match).
|
||||
func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"alice@example.com\": r\n"+
|
||||
"roles:\n document_controller:\n members:\n - root-dc@example.com\n")
|
||||
proj := filepath.Join(root, "Project")
|
||||
if err := os.Mkdir(proj, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(proj, ".zddc"),
|
||||
"roles:\n document_controller:\n members:\n - project-dc@example.com\n")
|
||||
zddc.InvalidateCache(root)
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", 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())
|
||||
}
|
||||
var view effectiveZddcView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
dc, ok := view.Merged.Roles["document_controller"]
|
||||
if !ok {
|
||||
t.Fatalf("merged.roles missing document_controller: %+v", view.Merged.Roles)
|
||||
}
|
||||
wantMembers := map[string]bool{
|
||||
"root-dc@example.com": true,
|
||||
"project-dc@example.com": true,
|
||||
}
|
||||
for _, m := range dc.Members {
|
||||
delete(wantMembers, m)
|
||||
}
|
||||
if len(wantMembers) > 0 {
|
||||
t.Errorf("document_controller members missing %v; got %v", wantMembers, dc.Members)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -272,6 +272,141 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul
|
|||
return pattern, merged, true
|
||||
}
|
||||
|
||||
// SourceEntry names one cascade contribution to an EffectiveZddc
|
||||
// composition. Level -1 is the embedded defaults baseline (chain.
|
||||
// Embedded); levels 0+ index into chain.Levels (root→leaf). Contributed
|
||||
// lists the top-level ZddcFile field names this level supplied a non-
|
||||
// zero value for — used by inspection clients to answer "where does
|
||||
// this value come from?" without re-walking the cascade.
|
||||
type SourceEntry struct {
|
||||
Level int `json:"level"`
|
||||
Contributed []string `json:"contributed,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveZddc composes the cascade into a single ZddcFile by walking
|
||||
// chain.Embedded then chain.Levels[VisibleStart..] through mergeOverlay,
|
||||
// and folding the cross-level Roles union (via lookupRoleMembers) into
|
||||
// merged.Roles so the result reflects the same role membership the
|
||||
// runtime ACL evaluator sees.
|
||||
//
|
||||
// Returned alongside is a per-source list of which top-level fields
|
||||
// each contributing level declared. Caller maps SourceEntry.Level to a
|
||||
// URL (-1 = embedded baseline; 0..len(chain.Levels)-1 = dirs along the
|
||||
// walk from fsRoot to the requested directory).
|
||||
//
|
||||
// Returns the zero ZddcFile + nil sources when the chain is empty.
|
||||
// Used by the ?effective=1 query on /.zddc — distinct from the .zddc
|
||||
// file itself, which serves only what's defined at the leaf level.
|
||||
func EffectiveZddc(chain PolicyChain) (ZddcFile, []SourceEntry) {
|
||||
if len(chain.Levels) == 0 {
|
||||
return ZddcFile{}, nil
|
||||
}
|
||||
sources := make([]SourceEntry, 0, len(chain.Levels)+1)
|
||||
var merged ZddcFile
|
||||
|
||||
// Embedded baseline (skipped when an inherit:false fence dropped
|
||||
// it; cascade.go zeroes chain.Embedded in that case).
|
||||
if c := nonZeroZddcFields(chain.Embedded); len(c) > 0 {
|
||||
merged = mergeOverlay(merged, chain.Embedded)
|
||||
sources = append(sources, SourceEntry{Level: -1, Contributed: c})
|
||||
}
|
||||
|
||||
leafIdx := len(chain.Levels) - 1
|
||||
floor := chain.VisibleStart(leafIdx)
|
||||
for i := floor; i <= leafIdx; i++ {
|
||||
lvl := chain.Levels[i]
|
||||
if c := nonZeroZddcFields(lvl); len(c) > 0 {
|
||||
merged = mergeOverlay(merged, lvl)
|
||||
sources = append(sources, SourceEntry{Level: i, Contributed: c})
|
||||
}
|
||||
}
|
||||
|
||||
// Roles: mergeOverlay does per-level name-keyed replacement, but
|
||||
// the runtime evaluator unions members across levels via
|
||||
// lookupRoleMembers (handling reset:true and the embedded
|
||||
// baseline). Re-resolve every role name reachable in the visible
|
||||
// chain so merged.Roles matches what ACL evaluation sees.
|
||||
roleNames := collectRoleNames(chain, floor, leafIdx)
|
||||
if len(roleNames) > 0 {
|
||||
out := make(map[string]Role, len(roleNames))
|
||||
for _, name := range roleNames {
|
||||
members, defined := lookupRoleMembers(chain, leafIdx, name)
|
||||
if !defined {
|
||||
continue
|
||||
}
|
||||
out[name] = Role{Members: members}
|
||||
}
|
||||
merged.Roles = out
|
||||
} else {
|
||||
merged.Roles = nil
|
||||
}
|
||||
|
||||
return merged, sources
|
||||
}
|
||||
|
||||
// nonZeroZddcFields returns the names of top-level ZddcFile fields zf
|
||||
// has populated. Field names match the yaml tags (so "acl" not "ACL").
|
||||
// Used to populate SourceEntry.Contributed.
|
||||
func nonZeroZddcFields(zf ZddcFile) []string {
|
||||
var out []string
|
||||
add := func(name string, cond bool) {
|
||||
if cond {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
add("title", zf.Title != "")
|
||||
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
||||
add("admins", len(zf.Admins) > 0)
|
||||
add("apps", len(zf.Apps) > 0)
|
||||
add("apps_pubkey", zf.AppsPubKey != "")
|
||||
add("tables", len(zf.Tables) > 0)
|
||||
add("display", len(zf.Display) > 0)
|
||||
add("convert", zf.Convert != nil)
|
||||
add("roles", len(zf.Roles) > 0)
|
||||
add("created_by", zf.CreatedBy != "")
|
||||
add("default_tool", zf.DefaultTool != "")
|
||||
add("dir_tool", zf.DirTool != "")
|
||||
add("auto_own", zf.AutoOwn != nil)
|
||||
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
||||
add("virtual", zf.Virtual != nil)
|
||||
add("drop_target", zf.DropTarget != nil)
|
||||
add("worm", zf.Worm != nil)
|
||||
add("available_tools", len(zf.AvailableTools) > 0)
|
||||
add("received_path", zf.ReceivedPath != "")
|
||||
add("planned_review_date", zf.PlannedReviewDate != "")
|
||||
add("planned_response_date", zf.PlannedResponseDate != "")
|
||||
add("field_codes", len(zf.FieldCodes) > 0)
|
||||
add("records", len(zf.Records) > 0)
|
||||
add("paths", len(zf.Paths) > 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// collectRoleNames returns every role name that has a definition in
|
||||
// any visible level (or the embedded baseline). Used by EffectiveZddc
|
||||
// to know which roles to resolve via lookupRoleMembers — without it
|
||||
// we'd miss roles declared only at an ancestor not directly merged at
|
||||
// the leaf level (since per-level mergeOverlay replaces Roles by key,
|
||||
// not by union).
|
||||
func collectRoleNames(chain PolicyChain, floor, leafIdx int) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for i := floor; i <= leafIdx; i++ {
|
||||
for name := range chain.Levels[i].Roles {
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
for name := range chain.Embedded.Roles {
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InvalidateCache removes the cached policy for dirPath and all descendants.
|
||||
func InvalidateCache(dirPath string) {
|
||||
dirPath = filepath.Clean(dirPath)
|
||||
|
|
|
|||
Loading…
Reference in a new issue