feat(zddc): add ProjectRootFolders/PartyFolders + ResolveCanonical helper
Introduce the lowercase canonical folder model that the new auto-create feature will key off: - ProjectRootFolders = [archive, working, staging, reviewing] - PartyFolders = [mdl, incoming, received, issued] - AutoOwnCanonicalNames = [working, staging, incoming] - VirtualOnlyCanonicalNames = [reviewing] ResolveCanonical(parentDir, logical) does a case-fold lookup against os.ReadDir(parentDir) so a manually-created Working/ is reused rather than shadowed by a new working/ sibling. Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames / WormFolderNames are kept (now Deprecated:) so dependent packages keep compiling until the predicate rewrite lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5363b5364c
commit
5fa5d13b10
2 changed files with 139 additions and 31 deletions
|
|
@ -1,46 +1,107 @@
|
||||||
package zddc
|
package zddc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpecialFolderNames is the canonical list of folder names that drive
|
// ProjectRootFolders are the canonical lowercase folder names that may
|
||||||
// per-tool availability rules and post-cascade access-decision behaviors.
|
// appear directly under a project root. The server resolves them
|
||||||
// Centralized here so apps/availability and the access-control evaluator
|
// case-insensitively on disk: a manually-created Working/ is reused
|
||||||
// share one source of truth.
|
// rather than shadowed by a new working/.
|
||||||
//
|
//
|
||||||
// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator
|
// - "archive" — formal record of issued/received transmittals,
|
||||||
// becomes the new subtree's admin).
|
// organised by counterparty (and ourselves)
|
||||||
// - "Working" — internal pre-publication workspace; mkdir auto-ownership.
|
// - "working" — user-owned drafting workspace
|
||||||
// - "Staging" — outbound transmittal staging; mkdir auto-ownership.
|
// - "staging" — outbound-transmittal preparation
|
||||||
// - "Issued" — immutable archive of documents we sent out. WORM mask
|
// - "reviewing" — purely virtual cross-reference of in-progress
|
||||||
// strips w/d/a from non-admin principals.
|
// review responses (never written to disk)
|
||||||
// - "Received" — immutable archive of documents we accepted. Same WORM
|
var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}
|
||||||
// semantics as Issued.
|
|
||||||
//
|
|
||||||
// Names are case-sensitive and exactly capitalized — operators name their
|
|
||||||
// folders this way by convention. A folder spelled differently (e.g.
|
|
||||||
// "incoming") is just a regular folder with no special semantics.
|
|
||||||
var SpecialFolderNames = []string{
|
|
||||||
"Incoming",
|
|
||||||
"Working",
|
|
||||||
"Staging",
|
|
||||||
"Issued",
|
|
||||||
"Received",
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoOwnFolderNames is the subset of SpecialFolderNames where the file
|
// PartyFolders are the canonical lowercase folder names that may appear
|
||||||
// API's mkdir post-hook auto-writes a creator-owned .zddc into the new
|
// directly under archive/<party>/, where <party> is a counterparty or
|
||||||
// subdirectory. Issued / Received are deliberately excluded — filing in
|
// the self-folder (we treat ourselves like any other third party).
|
||||||
// the immutable archive should not create owned subtrees inside it.
|
//
|
||||||
|
// - "mdl" — yaml-per-deliverable metadata, edited via the
|
||||||
|
// table-editor app at <party>/mdl.table.html
|
||||||
|
// - "incoming" — that party's drop point (we QC then promote)
|
||||||
|
// - "received" — immutable record of incoming we've accepted (WORM)
|
||||||
|
// - "issued" — immutable record of what we sent (WORM)
|
||||||
|
var PartyFolders = []string{"mdl", "incoming", "received", "issued"}
|
||||||
|
|
||||||
|
// AutoOwnCanonicalNames is the subset of canonical folder names where
|
||||||
|
// the file API's first-write hook auto-writes a creator-owned .zddc
|
||||||
|
// granting the creator rwcda. Excluded by design:
|
||||||
|
//
|
||||||
|
// - "archive": container only
|
||||||
|
// - "reviewing": purely virtual, never on disk
|
||||||
|
// - "mdl": yaml data store; ACL flows from archive/<party>/.zddc
|
||||||
|
// - "received" / "issued": WORM — auto-own would defeat the mask
|
||||||
|
var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}
|
||||||
|
|
||||||
|
// VirtualOnlyCanonicalNames is the subset of canonical folder names
|
||||||
|
// that are never materialised on disk by the auto-create hooks. The
|
||||||
|
// server treats requests under these prefixes as virtual routes.
|
||||||
|
//
|
||||||
|
// "reviewing" stays in ProjectRootFolders so case-fold recognition and
|
||||||
|
// future tool registration work, but EnsureCanonicalAncestors skips
|
||||||
|
// MkdirAll for it.
|
||||||
|
var VirtualOnlyCanonicalNames = []string{"reviewing"}
|
||||||
|
|
||||||
|
// SpecialFolderNames is preserved for callers that still reference the
|
||||||
|
// pre-canonical-folders model. Equivalent to the union of project-root
|
||||||
|
// AutoOwnCanonicalNames-at-root + WORM-canonical-names-at-party with
|
||||||
|
// the legacy PascalCase spellings, kept temporarily so dependent
|
||||||
|
// packages compile during the migration. New code should reference
|
||||||
|
// ProjectRootFolders / PartyFolders directly.
|
||||||
|
//
|
||||||
|
// Deprecated: use ProjectRootFolders or PartyFolders.
|
||||||
|
var SpecialFolderNames = []string{"Incoming", "Working", "Staging", "Issued", "Received"}
|
||||||
|
|
||||||
|
// AutoOwnFolderNames is the legacy capitalised list. Predicates have
|
||||||
|
// moved to IsAutoOwnPath which understands the new layout.
|
||||||
|
//
|
||||||
|
// Deprecated: use AutoOwnCanonicalNames.
|
||||||
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
|
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
|
||||||
|
|
||||||
// WormFolderNames is the subset of SpecialFolderNames covered by the
|
// WormFolderNames is the legacy capitalised list. Predicates have
|
||||||
// post-cascade WORM mask. Any path whose chain crosses one of these
|
// moved to IsWormPath which understands the per-party layout.
|
||||||
// names has w/d/a stripped from non-admin principals.
|
//
|
||||||
|
// Deprecated: use PartyFolders + IsWormPath.
|
||||||
var WormFolderNames = []string{"Issued", "Received"}
|
var WormFolderNames = []string{"Issued", "Received"}
|
||||||
|
|
||||||
|
// ResolveCanonical returns the on-disk name of the canonical folder
|
||||||
|
// 'logical' (lowercase) inside parentDir, or "" if no case variant
|
||||||
|
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
|
||||||
|
// when "" is returned.
|
||||||
|
//
|
||||||
|
// 'logical' is matched case-insensitively against entries returned by
|
||||||
|
// os.ReadDir(parentDir). The first matching directory entry wins (if
|
||||||
|
// an operator created both Working/ and working/ on a case-sensitive
|
||||||
|
// filesystem, the order is filesystem-dependent — that's an unsupported
|
||||||
|
// state we don't try to recover from).
|
||||||
|
//
|
||||||
|
// Returns "" with no error if parentDir doesn't exist or has no match.
|
||||||
|
func ResolveCanonical(parentDir, logical string) (string, error) {
|
||||||
|
entries, err := os.ReadDir(parentDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(e.Name(), logical) {
|
||||||
|
return e.Name(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsAutoOwnParent reports whether a folder named name should trigger
|
// IsAutoOwnParent reports whether a folder named name should trigger
|
||||||
// the mkdir auto-ownership .zddc write when a child is created inside
|
// the mkdir auto-ownership .zddc write when a child is created inside
|
||||||
// it. Used by the file API's mkdir handler.
|
// it. Used by the file API's mkdir handler.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package zddc
|
package zddc
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestIsAutoOwnParent(t *testing.T) {
|
func TestIsAutoOwnParent(t *testing.T) {
|
||||||
yes := []string{"Incoming", "Working", "Staging"}
|
yes := []string{"Incoming", "Working", "Staging"}
|
||||||
|
|
@ -60,6 +64,49 @@ func TestWormMaskStripsWDA(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCanonicalCaseFold(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "Working"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "ARCHIVE"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A regular file with a canonical name must NOT be returned (we only resolve directories).
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "staging"), []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"working": "Working", // PascalCase wins because it exists on disk
|
||||||
|
"WORKING": "Working",
|
||||||
|
"Working": "Working",
|
||||||
|
"archive": "ARCHIVE",
|
||||||
|
"reviewing": "", // not present
|
||||||
|
"staging": "", // present as a file, not a directory — must skip
|
||||||
|
}
|
||||||
|
for logical, want := range cases {
|
||||||
|
got, err := ResolveCanonical(dir, logical)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ResolveCanonical(%q): %v", logical, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ResolveCanonical(%q) = %q, want %q", logical, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCanonicalMissingParent(t *testing.T) {
|
||||||
|
got, err := ResolveCanonical(filepath.Join(t.TempDir(), "does-not-exist"), "working")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil error for missing parent, got %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty result for missing parent, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
|
func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
|
||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
"Incoming": false, "Working": false, "Staging": false,
|
"Incoming": false, "Working": false, "Staging": false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue