Compare commits
No commits in common. "821ed3ee192f442e93d7d0a2e5e59565e3098d6b" and "f6dc9d557a44c5ee65327db623d96b5024c64d47" have entirely different histories.
821ed3ee19
...
f6dc9d557a
22 changed files with 277 additions and 1945 deletions
|
|
@ -2,12 +2,6 @@
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Commits and pushes
|
|
||||||
|
|
||||||
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
|
|
||||||
- **Push only when explicitly told** — `git push` requires a fresh request from the user every time. Approval to commit does not carry forward to push, and approval to push once does not carry forward to a later push.
|
|
||||||
- **No squashing on push** — keep granular history. Each commit should already be meaningful (per the rule above), so squashing erases useful detail rather than removing noise. Multi-commit branches with a clean history are preferred over force-pushed squash-merges.
|
|
||||||
|
|
||||||
## Authoritative docs — read these first
|
## Authoritative docs — read these first
|
||||||
|
|
||||||
This repo already has two thorough agent-facing references. **Always consult them before working** — they cover details intentionally omitted here:
|
This repo already has two thorough agent-facing references. **Always consult them before working** — they cover details intentionally omitted here:
|
||||||
|
|
|
||||||
|
|
@ -529,23 +529,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
|
|
||||||
// The table app is the canonical view for the per-party Master
|
|
||||||
// Deliverables List. Direct navigation to the data folder lands on
|
|
||||||
// the grid editor; clients that want the raw row listing can still
|
|
||||||
// hit archive/<party>/mdl/ via the file API or with an explicit
|
|
||||||
// trailing-segment beyond mdl/.
|
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
|
|
||||||
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
|
|
||||||
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
|
|
||||||
if r.URL.RawQuery != "" {
|
|
||||||
target += "?" + r.URL.RawQuery
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tables-system intercept: *.table.html is a virtual URL that the
|
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||||
// table handler renders inline, reading rows from a directory of
|
// table handler renders inline, reading rows from a directory of
|
||||||
// *.yaml files declared in the directory's .zddc tables: map.
|
// *.yaml files declared in the directory's .zddc tables: map.
|
||||||
|
|
@ -553,10 +536,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// so RecognizeTableRequest returns nil whenever there's no matching
|
// so RecognizeTableRequest returns nil whenever there's no matching
|
||||||
// declaration and the URL falls through to the static-file path
|
// declaration and the URL falls through to the static-file path
|
||||||
// (or to the form intercept below for *.form.html / *.yaml.html).
|
// (or to the form intercept below for *.form.html / *.yaml.html).
|
||||||
//
|
|
||||||
// One exception: archive/<party>/mdl.table.html falls back to the
|
|
||||||
// embedded default MDL spec when no operator declaration exists.
|
|
||||||
// RecognizeTableRequest implements that fallback internally.
|
|
||||||
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
|
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
|
||||||
handler.ServeTable(cfg, tableReq, w, r)
|
handler.ServeTable(cfg, tableReq, w, r)
|
||||||
return
|
return
|
||||||
|
|
@ -617,34 +596,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
info, err := os.Stat(absPath)
|
info, err := os.Stat(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
|
|
||||||
// and archive/<party>/mdl.form.yaml are served from embedded
|
|
||||||
// bytes when no operator file exists on disk. The table app
|
|
||||||
// fetches these client-side; the fallback lets a fresh
|
|
||||||
// project work out of the box.
|
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
||||||
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
|
|
||||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
|
||||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(bytes)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// File doesn't exist at this path. If the URL matches one of
|
// File doesn't exist at this path. If the URL matches one of
|
||||||
// the canonical app HTML names AND the request directory is
|
// the five canonical app HTML names AND the request directory
|
||||||
// one where that app is available (working/staging/incoming
|
// is one where that app is available (Incoming/Working/Staging
|
||||||
// for classifier, working for mdedit, staging for
|
// for classifier/mdedit/transmittal, anywhere for archive,
|
||||||
// transmittal, anywhere for archive, root only for landing),
|
// root only for landing), resolve via the apps subsystem.
|
||||||
// resolve via the apps subsystem.
|
|
||||||
if appsSrv != nil {
|
if appsSrv != nil {
|
||||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||||
|
|
|
||||||
|
|
@ -373,79 +373,6 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDispatchMdlRedirect(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
||||||
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
|
||||||
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
|
||||||
|
|
||||||
idx, err := archive.BuildIndex(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildIndex: %v", err)
|
|
||||||
}
|
|
||||||
cfg := config.Config{
|
|
||||||
Root: root,
|
|
||||||
IndexPath: ".archive",
|
|
||||||
EmailHeader: "X-Auth-Request-Email",
|
|
||||||
}
|
|
||||||
ring := handler.NewLogRing(10)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
wantStatus int
|
|
||||||
wantLoc string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"mdl trailing slash → table",
|
|
||||||
"/ProjectA/archive/Acme/mdl/",
|
|
||||||
http.StatusFound,
|
|
||||||
"/ProjectA/archive/Acme/mdl.table.html",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case-fold MDL trailing slash",
|
|
||||||
"/ProjectA/archive/Acme/MDL/",
|
|
||||||
http.StatusFound,
|
|
||||||
"/ProjectA/archive/Acme/mdl.table.html",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case-fold ARCHIVE",
|
|
||||||
"/ProjectA/Archive/Acme/mdl/",
|
|
||||||
http.StatusFound,
|
|
||||||
"/ProjectA/Archive/Acme/mdl.table.html",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deeper than party-level mdl is NOT redirected",
|
|
||||||
"/ProjectA/archive/Acme/incoming/mdl/",
|
|
||||||
// Falls through to static-file pipeline; no folder exists there → 404.
|
|
||||||
http.StatusNotFound,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"working/mdl is NOT redirected (not under archive)",
|
|
||||||
"/ProjectA/working/mdl/",
|
|
||||||
http.StatusNotFound,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
dispatch(cfg, idx, ring, nil, rec, req)
|
|
||||||
if rec.Code != tc.wantStatus {
|
|
||||||
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
||||||
}
|
|
||||||
if tc.wantLoc != "" {
|
|
||||||
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
|
||||||
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
||||||
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
||||||
// API's write path, so a write to an archive URL never silently mutates
|
// API's write path, so a write to an archive URL never silently mutates
|
||||||
|
|
|
||||||
|
|
@ -3,55 +3,66 @@ package apps
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Folder name conventions that gate which tools are virtually available
|
||||||
|
// at a given path. The names are case-sensitive; ZDDC convention uses
|
||||||
|
// the capitalized forms. The full canonical list lives in
|
||||||
|
// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls
|
||||||
|
// the relevant subsets from there to avoid duplication.
|
||||||
|
var (
|
||||||
|
// Subset of zddc.AutoOwnFolderNames where classifier is virtually
|
||||||
|
// available (the same three folders that grant mkdir auto-ownership).
|
||||||
|
folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames
|
||||||
|
folderNamesWorking = []string{"Working"}
|
||||||
|
folderNamesStaging = []string{"Staging"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||||
// requestDir. Rules (case-insensitive on canonical folder names):
|
// requestDir. Rules:
|
||||||
//
|
//
|
||||||
// - archive: every directory (multi-project, project, archive, party)
|
// - archive: every directory (multi-project, project, archive, vendor)
|
||||||
// - browse: every directory (generic file listing — also the default
|
// - browse: every directory (generic file listing — also the default
|
||||||
// served at folder URLs without an index.html; see directory.go)
|
// served at folder URLs without an index.html; see directory.go)
|
||||||
// - classifier: requestDir is, or descends from, a folder named
|
// - classifier: requestDir is, or descends from, a folder named
|
||||||
// "working", "staging", or "incoming" (the directories where
|
// "Incoming", "Working", or "Staging" (the directories where
|
||||||
// in-flight files get classified)
|
// incoming/outgoing files get classified)
|
||||||
// - mdedit: requestDir is, or descends from, a "working" folder
|
// - mdedit: requestDir is, or descends from, a "Working" folder
|
||||||
// (where markdown drafts are written and edited, including review
|
// (where markdown drafts are written and edited)
|
||||||
// responses drafted in working/<rs-name>/)
|
// - transmittal: requestDir is, or descends from, a "Staging" folder
|
||||||
// - transmittal: requestDir is, or descends from, a "staging" folder
|
|
||||||
// (where outgoing transmittals are prepared)
|
// (where outgoing transmittals are prepared)
|
||||||
// - landing: only at the deployment root (the project picker)
|
// - landing: only at the deployment root (the project picker)
|
||||||
//
|
//
|
||||||
// Operators can always drop a real <name>.html file at any path to
|
// Operators can always drop a real <name>.html file at any path to override
|
||||||
// override — that path is served by the static handler regardless of
|
// — that path is served by the static handler regardless of this function's
|
||||||
// this function's result. AppAvailableAt is consulted only when no
|
// result. AppAvailableAt is consulted only when no real file exists.
|
||||||
// real file exists.
|
|
||||||
//
|
|
||||||
// In the canonical layout, "incoming" only appears at
|
|
||||||
// archive/<party>/incoming/, so checking "any ancestor named incoming"
|
|
||||||
// is equivalent to checking "under a per-party incoming folder."
|
|
||||||
func AppAvailableAt(root, requestDir, app string) bool {
|
func AppAvailableAt(root, requestDir, app string) bool {
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
requestDir = filepath.Clean(requestDir)
|
requestDir = filepath.Clean(requestDir)
|
||||||
|
|
||||||
switch app {
|
switch app {
|
||||||
case "archive", "browse":
|
case "archive":
|
||||||
|
return true
|
||||||
|
case "browse":
|
||||||
return true
|
return true
|
||||||
case "landing":
|
case "landing":
|
||||||
return requestDir == root
|
return requestDir == root
|
||||||
case "classifier":
|
case "classifier":
|
||||||
return inAncestorWithName(root, requestDir, "working", "staging", "incoming")
|
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
|
||||||
case "mdedit":
|
case "mdedit":
|
||||||
return inAncestorWithName(root, requestDir, "working")
|
return inAncestorWithName(root, requestDir, folderNamesWorking)
|
||||||
case "transmittal":
|
case "transmittal":
|
||||||
return inAncestorWithName(root, requestDir, "staging")
|
return inAncestorWithName(root, requestDir, folderNamesStaging)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
||||||
// (not including root itself), whose last segment case-folds to one
|
// (not including root itself), named one of names. The match is on the
|
||||||
// of names. Match is on segment names, case-insensitively.
|
// last segment of each directory in the chain root → requestDir.
|
||||||
func inAncestorWithName(root, requestDir string, names ...string) bool {
|
func inAncestorWithName(root, requestDir string, names []string) bool {
|
||||||
if requestDir == root {
|
if requestDir == root {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +72,7 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
|
||||||
}
|
}
|
||||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
if strings.EqualFold(part, n) {
|
if part == n {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,46 +14,38 @@ func TestAppAvailableAt(t *testing.T) {
|
||||||
// archive: everywhere
|
// archive: everywhere
|
||||||
{root, "archive", true},
|
{root, "archive", true},
|
||||||
{root + "/Project-A", "archive", true},
|
{root + "/Project-A", "archive", true},
|
||||||
{root + "/Project-A/working", "archive", true},
|
{root + "/Project-A/Working", "archive", true},
|
||||||
{root + "/Project-A/some-other-folder", "archive", true},
|
{root + "/Project-A/Outgoing", "archive", true},
|
||||||
|
|
||||||
// landing: only at root
|
// landing: only at root
|
||||||
{root, "landing", true},
|
{root, "landing", true},
|
||||||
{root + "/Project-A", "landing", false},
|
{root + "/Project-A", "landing", false},
|
||||||
|
|
||||||
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
|
// classifier: Incoming/Working/Staging and subtrees
|
||||||
{root, "classifier", false},
|
{root, "classifier", false},
|
||||||
{root + "/Project-A", "classifier", false},
|
{root + "/Project-A", "classifier", false},
|
||||||
{root + "/Project-A/working", "classifier", true},
|
{root + "/Project-A/Incoming", "classifier", true},
|
||||||
{root + "/Project-A/working/deep/nested/path", "classifier", true},
|
{root + "/Project-A/Incoming/SubDir", "classifier", true},
|
||||||
{root + "/Project-A/staging", "classifier", true},
|
{root + "/Project-A/Working", "classifier", true},
|
||||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
{root + "/Project-A/Staging", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
{root + "/Project-A/Outgoing", "classifier", false},
|
||||||
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
{root + "/Project-A/Working/deep/nested/path", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
|
||||||
{root + "/Project-A/archive/ACME/issued", "classifier", false},
|
|
||||||
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
|
|
||||||
{root + "/Project-A/some-other-folder", "classifier", false},
|
|
||||||
|
|
||||||
// mdedit: working/ only (review responses live in working/<rs-name>/)
|
// mdedit: Working only
|
||||||
{root + "/Project-A/working", "mdedit", true},
|
|
||||||
{root + "/Project-A/working/sub", "mdedit", true},
|
|
||||||
{root + "/Project-A/staging", "mdedit", false},
|
|
||||||
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
|
|
||||||
|
|
||||||
// transmittal: staging/ only
|
|
||||||
{root + "/Project-A/staging", "transmittal", true},
|
|
||||||
{root + "/Project-A/staging/sub", "transmittal", true},
|
|
||||||
{root + "/Project-A/working", "transmittal", false},
|
|
||||||
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
|
||||||
|
|
||||||
// case-fold: any case of canonical names matches
|
|
||||||
{root + "/Project-A/Working", "mdedit", true},
|
{root + "/Project-A/Working", "mdedit", true},
|
||||||
{root + "/Project-A/WORKING", "mdedit", true},
|
{root + "/Project-A/Working/SubDir", "mdedit", true},
|
||||||
|
{root + "/Project-A/Incoming", "mdedit", false},
|
||||||
|
{root + "/Project-A/Staging", "mdedit", false},
|
||||||
|
|
||||||
|
// transmittal: Staging only
|
||||||
{root + "/Project-A/Staging", "transmittal", true},
|
{root + "/Project-A/Staging", "transmittal", true},
|
||||||
{root + "/Project-A/STAGING", "transmittal", true},
|
{root + "/Project-A/Staging/SubDir", "transmittal", true},
|
||||||
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
{root + "/Project-A/Incoming", "transmittal", false},
|
||||||
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
{root + "/Project-A/Working", "transmittal", false},
|
||||||
|
|
||||||
|
// case-sensitivity: lowercase doesn't match
|
||||||
|
{root + "/Project-A/working", "mdedit", false},
|
||||||
|
{root + "/Project-A/staging", "transmittal", false},
|
||||||
|
|
||||||
// unknown app
|
// unknown app
|
||||||
{root + "/Project-A", "weird", false},
|
{root + "/Project-A", "weird", false},
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RevisionEntry holds the resolved file paths for one base revision.
|
// RevisionEntry holds the resolved file paths for one base revision.
|
||||||
|
|
@ -51,6 +49,9 @@ func NewIndex() *Index {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transmittalFolderRE matches: YYYY-MM-DD_anything (anything) - anything
|
||||||
|
var transmittalFolderRE = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})_[^_\s]+\s*\([^)]+\)\s*-\s*.+$`)
|
||||||
|
|
||||||
// zddc filename: trackingNumber_revision (status) - title.ext
|
// zddc filename: trackingNumber_revision (status) - title.ext
|
||||||
// trackingNumber: no spaces or underscores
|
// trackingNumber: no spaces or underscores
|
||||||
// revision: ~?[A-Z0-9]+(+[CBNQ][0-9]+)?
|
// revision: ~?[A-Z0-9]+(+[CBNQ][0-9]+)?
|
||||||
|
|
@ -100,8 +101,9 @@ func walkAndIndex(idx *Index, fsRoot, dirAbs, serverDir string) error {
|
||||||
}
|
}
|
||||||
childAbs := filepath.Join(dirAbs, name)
|
childAbs := filepath.Join(dirAbs, name)
|
||||||
|
|
||||||
if date, _, _, _, ok := zddc.ParseTransmittalFolder(name); ok {
|
if m := transmittalFolderRE.FindStringSubmatch(name); m != nil {
|
||||||
// This is a transmittal folder — index its files
|
// This is a transmittal folder — index its files
|
||||||
|
date := m[1]
|
||||||
if err := indexTransmittalFolder(idx, fsRoot, childAbs, childServerDir, date); err != nil {
|
if err := indexTransmittalFolder(idx, fsRoot, childAbs, childServerDir, date); err != nil {
|
||||||
// Non-fatal: log and continue
|
// Non-fatal: log and continue
|
||||||
continue
|
continue
|
||||||
|
|
@ -338,10 +340,11 @@ func (idx *Index) Rebuild(fsRoot string) (time.Duration, int, int, error) {
|
||||||
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
|
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
|
||||||
// Determine the date from the folder name
|
// Determine the date from the folder name
|
||||||
folderName := filepath.Base(transmittalDirPath)
|
folderName := filepath.Base(transmittalDirPath)
|
||||||
date, _, _, _, ok := zddc.ParseTransmittalFolder(folderName)
|
m := transmittalFolderRE.FindStringSubmatch(folderName)
|
||||||
if !ok {
|
if m == nil {
|
||||||
return nil // not a transmittal folder
|
return nil // not a transmittal folder
|
||||||
}
|
}
|
||||||
|
date := m[1]
|
||||||
|
|
||||||
// Compute server-relative path for this folder
|
// Compute server-relative path for this folder
|
||||||
rel, err := filepath.Rel(fsRoot, transmittalDirPath)
|
rel, err := filepath.Rel(fsRoot, transmittalDirPath)
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||||
// For transmittal folder events, schedule a debounced index update
|
// For transmittal folder events, schedule a debounced index update
|
||||||
dirPath := filepath.Dir(path)
|
dirPath := filepath.Dir(path)
|
||||||
dirName := filepath.Base(dirPath)
|
dirName := filepath.Base(dirPath)
|
||||||
if _, _, _, _, ok := zddc.ParseTransmittalFolder(dirName); ok {
|
if transmittalFolderRE.MatchString(dirName) {
|
||||||
w.scheduleIndexUpdate(dirPath)
|
w.scheduleIndexUpdate(dirPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,50 +103,5 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user virtual home: when listing <project>/working/ for an
|
|
||||||
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
|
|
||||||
// no real folder of any case variant already exists for them. A
|
|
||||||
// first write to that path materialises a real folder with auto-own
|
|
||||||
// .zddc; subsequent listings drop the synthetic entry naturally.
|
|
||||||
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
|
|
||||||
result = append(result, syn)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
|
||||||
// should be appended to a working/ listing, or (zero, false) when no
|
|
||||||
// synthetic entry applies.
|
|
||||||
//
|
|
||||||
// Conditions for the entry to fire:
|
|
||||||
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
|
|
||||||
// - viewerEmail is non-empty
|
|
||||||
// - real does not already contain a directory entry that case-folds
|
|
||||||
// to viewerEmail (so a materialised home doesn't get duplicated)
|
|
||||||
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
|
||||||
if viewerEmail == "" {
|
|
||||||
return listing.FileInfo{}, false
|
|
||||||
}
|
|
||||||
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") {
|
|
||||||
return listing.FileInfo{}, false
|
|
||||||
}
|
|
||||||
for _, fi := range real {
|
|
||||||
if !fi.IsDir {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// fi.Name carries a trailing slash for dirs.
|
|
||||||
bare := strings.TrimSuffix(fi.Name, "/")
|
|
||||||
if strings.EqualFold(bare, viewerEmail) {
|
|
||||||
return listing.FileInfo{}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return listing.FileInfo{
|
|
||||||
Name: viewerEmail + "/",
|
|
||||||
URL: baseURL + url.PathEscape(viewerEmail) + "/",
|
|
||||||
IsDir: true,
|
|
||||||
Virtual: true,
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
package fs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTreeRoot(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
// Permissive root .zddc so subdirectory ACL checks pass.
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var virtual *string
|
|
||||||
for i := range got {
|
|
||||||
if got[i].Virtual {
|
|
||||||
n := got[i].Name
|
|
||||||
virtual = &n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if virtual == nil {
|
|
||||||
t.Fatalf("expected synthetic <viewer-email>/ entry, got entries: %+v", got)
|
|
||||||
}
|
|
||||||
if *virtual != "alice@example.com/" {
|
|
||||||
t.Errorf("synthetic name = %q, want alice@example.com/", *virtual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
// A real folder exists for the viewer (any case).
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Virtual {
|
|
||||||
t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Virtual {
|
|
||||||
t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Virtual {
|
|
||||||
t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
// Listing inside working/ at depth 3+ — no synthetic entry should fire.
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
|
||||||
"Proj/working/alice@example.com", "alice@example.com",
|
|
||||||
"/Proj/working/alice@example.com/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Virtual {
|
|
||||||
t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
|
||||||
root := setupTreeRoot(t)
|
|
||||||
// Pre-existing PascalCase Working/.
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Virtual {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# Default row schema for a Master Deliverables List entry. Served by
|
|
||||||
# zddc-server when no operator-supplied mdl.form.yaml exists at
|
|
||||||
# archive/<party>/. Operators can override per-party.
|
|
||||||
|
|
||||||
title: Deliverable
|
|
||||||
description: One planned or in-flight deliverable for this party.
|
|
||||||
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [tracking, title]
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
tracking:
|
|
||||||
type: string
|
|
||||||
title: Tracking number
|
|
||||||
description: ZDDC tracking identifier (e.g. proj-EM-SPC-0001).
|
|
||||||
minLength: 1
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
title: Deliverable title
|
|
||||||
minLength: 1
|
|
||||||
discipline:
|
|
||||||
type: string
|
|
||||||
title: Discipline
|
|
||||||
description: Engineering discipline code (EM, EL, MC, ST, ...).
|
|
||||||
type:
|
|
||||||
type: string
|
|
||||||
title: Document type
|
|
||||||
description: Code for document class (SPC, DWG, RPT, ...).
|
|
||||||
plannedRevision:
|
|
||||||
type: string
|
|
||||||
title: Planned revision
|
|
||||||
description: Issue revision label, e.g. A, B, IFR, IFC.
|
|
||||||
plannedDate:
|
|
||||||
type: string
|
|
||||||
title: Planned date
|
|
||||||
format: date
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
title: Current status
|
|
||||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
|
||||||
owner:
|
|
||||||
type: string
|
|
||||||
title: Owner
|
|
||||||
description: Email or party name responsible for producing this row.
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
title: Notes
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# Default Master Deliverables List spec, served by zddc-server when no
|
|
||||||
# operator-supplied mdl.table.yaml exists at archive/<party>/. Operators
|
|
||||||
# can override per-party by writing their own file at
|
|
||||||
# archive/<party>/mdl.table.yaml plus a tables: { mdl: ./mdl.table.yaml }
|
|
||||||
# entry in the party's .zddc.
|
|
||||||
|
|
||||||
title: Master Deliverables List
|
|
||||||
description: Planned and actual deliverables for this party.
|
|
||||||
|
|
||||||
rowSchema: ./mdl.form.yaml
|
|
||||||
rows: ./mdl
|
|
||||||
|
|
||||||
columns:
|
|
||||||
- field: tracking
|
|
||||||
title: Tracking
|
|
||||||
width: 11em
|
|
||||||
sort: asc
|
|
||||||
- field: title
|
|
||||||
title: Deliverable
|
|
||||||
- field: discipline
|
|
||||||
title: Disc.
|
|
||||||
width: 5em
|
|
||||||
- field: type
|
|
||||||
title: Type
|
|
||||||
width: 6em
|
|
||||||
- field: plannedRevision
|
|
||||||
title: Rev.
|
|
||||||
width: 5em
|
|
||||||
- field: plannedDate
|
|
||||||
title: Planned
|
|
||||||
format: date
|
|
||||||
- field: status
|
|
||||||
title: Status
|
|
||||||
width: 6em
|
|
||||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
|
||||||
- field: owner
|
|
||||||
title: Owner
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
sort:
|
|
||||||
- { field: plannedDate, dir: asc }
|
|
||||||
|
|
@ -275,15 +275,6 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve canonical-folder casing on the way in (no side effects): a
|
|
||||||
// request for /Project/working/foo.md when the on-disk folder is
|
|
||||||
// Working/ should land in Working/, not create a duplicate sibling.
|
|
||||||
// The actual MkdirAll for missing canonical ancestors and the
|
|
||||||
// auto-own .zddc seeding happen after authorisation, below.
|
|
||||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
|
||||||
abs = r2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat first so we can choose action=create vs action=write before the
|
// Stat first so we can choose action=create vs action=write before the
|
||||||
// ACL gate runs — this matters because role grants may include `c` but
|
// ACL gate runs — this matters because role grants may include `c` but
|
||||||
// not `w` (or vice versa), and the gate must check the right verb.
|
// not `w` (or vice versa), and the gate must check the right verb.
|
||||||
|
|
@ -317,14 +308,6 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now that the write is authorized, materialise any missing canonical
|
|
||||||
// ancestors and seed auto-own .zddc files for them.
|
|
||||||
if email := EmailFromContext(r); email != "" {
|
|
||||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
|
|
||||||
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := zddc.WriteAtomic(abs, body); err != nil {
|
if err := zddc.WriteAtomic(abs, body); err != nil {
|
||||||
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -437,14 +420,6 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve canonical-folder casing on src + dst (no side effects).
|
|
||||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
|
|
||||||
srcAbs = r2
|
|
||||||
}
|
|
||||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil {
|
|
||||||
dstAbs = r2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source must exist as a regular file.
|
// Source must exist as a regular file.
|
||||||
srcInfo, err := os.Stat(srcAbs)
|
srcInfo, err := os.Stat(srcAbs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -483,15 +458,7 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure destination's canonical ancestors are created (with auto-own
|
// Ensure destination's parent directory exists.
|
||||||
// .zddc seeding) before the rename. This lets a MOVE from working/foo
|
|
||||||
// → archive/<party>/issued/foo materialise the per-party folders on
|
|
||||||
// the way in.
|
|
||||||
if email := EmailFromContext(r); email != "" {
|
|
||||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil {
|
|
||||||
slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
||||||
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
|
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -526,11 +493,6 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve canonical-folder casing on the way in (no side effects).
|
|
||||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
|
||||||
abs = r2
|
|
||||||
}
|
|
||||||
|
|
||||||
if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
|
if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -548,110 +510,44 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materialise any missing canonical ancestors (working/, staging/,
|
|
||||||
// archive/<party>/incoming/) before creating the target itself. This
|
|
||||||
// also seeds auto-own .zddc on each newly-created canonical ancestor.
|
|
||||||
email := EmailFromContext(r)
|
|
||||||
if email != "" {
|
|
||||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
|
|
||||||
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||||
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
|
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-ownership for the newly-created directory itself.
|
// Auto-ownership: when the parent directory is one of the
|
||||||
//
|
// auto-own special folders (Incoming/Working/Staging) and the
|
||||||
// Two cases yield an auto-own .zddc inside abs:
|
// caller has an authenticated email, write a .zddc into the new
|
||||||
// - The new directory is itself a canonical auto-own position
|
// folder granting the creator full control. The grant is identical
|
||||||
// (e.g. an explicit MKCOL of /Project/working). In this case
|
// to what the operator would write by hand — direct email pattern,
|
||||||
// IsAutoOwnPath(abs, cfg.Root) is true.
|
// "rwcda" verb set — so the creator can later edit the file
|
||||||
// - The new directory's parent is canonical auto-own — every child
|
// normally to add collaborators.
|
||||||
// mkdir under working/, staging/, or archive/<party>/incoming/
|
if email := EmailFromContext(r); email != "" {
|
||||||
// gets the creator's grant.
|
parentName := filepath.Base(filepath.Dir(abs))
|
||||||
if email != "" {
|
if zddc.IsAutoOwnParent(parentName) {
|
||||||
if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
|
if err := writeAutoOwnZddc(abs, email); err != nil {
|
||||||
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
|
|
||||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
|
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Staging↔working mirror: when a folder created under staging/ matches
|
|
||||||
// the ZDDC transmittal-folder grammar AND its tracking number contains
|
|
||||||
// -SUB- or -TRN-, also create the same-named folder under working/ as
|
|
||||||
// a drafting space for staff. The mirror is one-way and one-shot —
|
|
||||||
// renames or deletions of either side are not propagated.
|
|
||||||
if email != "" {
|
|
||||||
mirrorStagingToWorking(cfg, abs, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrorStagingToWorking creates a paired drafting folder under working/
|
// writeAutoOwnZddc serializes a creator-grant .zddc into newDir.
|
||||||
// when newAbs is a transmittal-named folder under <project>/staging/. Best
|
// Marshals via the same yaml encoder ParseFile reads (round-trip
|
||||||
// effort — failures are logged but do not affect the staging mkdir result.
|
// guaranteed) and writes atomically via zddc.WriteAtomic.
|
||||||
//
|
func writeAutoOwnZddc(newDir, email string) error {
|
||||||
// Eligibility:
|
zf := zddc.ZddcFile{
|
||||||
// - newAbs's parent is exactly <project>/staging/ (case-fold)
|
ACL: zddc.ACLRules{
|
||||||
// - filepath.Base(newAbs) parses as a transmittal folder
|
Permissions: map[string]string{email: "rwcda"},
|
||||||
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
|
},
|
||||||
// - tracking contains -SUB- or -TRN- (case-fold)
|
CreatedBy: email,
|
||||||
//
|
|
||||||
// Side effects on success:
|
|
||||||
// - <project>/working/ created if missing, with auto-own .zddc seeded
|
|
||||||
// (via EnsureCanonicalAncestors)
|
|
||||||
// - <project>/working/<sameName>/ created if missing, with its own
|
|
||||||
// auto-own .zddc (it's a child of the working/ canonical folder)
|
|
||||||
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
|
|
||||||
rel, err := filepath.Rel(cfg.Root, newAbs)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
// Mirror only fires for direct children of staging/. Deeper paths
|
|
||||||
// (staging/<name>/sub/) are user-managed.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(parts[1], "staging") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
name := parts[2]
|
|
||||||
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
|
|
||||||
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
|
|
||||||
// Idempotent: skip if the working sibling already exists.
|
|
||||||
if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
|
|
||||||
// if missing; we then MkdirAll the mirror folder itself and seed its
|
|
||||||
// auto-own grant.
|
|
||||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
|
|
||||||
slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
|
|
||||||
slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
|
|
||||||
slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
|
|
||||||
}
|
}
|
||||||
|
return zddc.WriteFile(newDir, zf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditFile emits a structured log line for each file API operation.
|
// auditFile emits a structured log line for each file API operation.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -379,22 +378,19 @@ func TestFileAPI_AnonymousDenied(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rolePermissionsTestSetup creates a project + per-party exchange shape:
|
// rolePermissionsTestSetup creates a vendor-exchange shape:
|
||||||
//
|
//
|
||||||
// root .zddc: _company:r, _doc_controller:rwcda
|
// root .zddc: _company:r, _doc_controller:rwcda
|
||||||
// <project>/archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
|
// Vendor/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
|
||||||
// roles defined at root.
|
// roles defined at root.
|
||||||
//
|
//
|
||||||
// The project is "Project-X"; the counterparty is "Acme". URLs target
|
|
||||||
// paths like /Project-X/archive/Acme/incoming/<file>.
|
|
||||||
//
|
|
||||||
// Returns the same do() helper as fileAPITestSetup.
|
// Returns the same do() helper as fileAPITestSetup.
|
||||||
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root = t.TempDir()
|
root = t.TempDir()
|
||||||
|
|
||||||
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
|
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
|
||||||
// defined here so the per-party subtree's permissions can reference
|
// defined here so the vendor subtree's permissions can reference
|
||||||
// them by name.
|
// them by name.
|
||||||
rootZ := []byte(`roles:
|
rootZ := []byte(`roles:
|
||||||
_company:
|
_company:
|
||||||
|
|
@ -412,21 +408,25 @@ acl:
|
||||||
t.Fatalf("root .zddc: %v", err)
|
t.Fatalf("root .zddc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project + per-party canonical layout.
|
// Vendor subtree: narrow scope.
|
||||||
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
|
vendorDir := filepath.Join(root, "Vendor")
|
||||||
for _, sub := range []string{"incoming", "issued", "received"} {
|
if err := os.MkdirAll(filepath.Join(vendorDir, "Incoming"), 0o755); err != nil {
|
||||||
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
|
t.Fatalf("mkdir Vendor/Incoming: %v", err)
|
||||||
t.Fatalf("mkdir party/%s: %v", sub, err)
|
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(vendorDir, "Issued"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir Vendor/Issued: %v", err)
|
||||||
}
|
}
|
||||||
partyZ := []byte(`acl:
|
if err := os.MkdirAll(filepath.Join(vendorDir, "Received"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir Vendor/Received: %v", err)
|
||||||
|
}
|
||||||
|
vendorZ := []byte(`acl:
|
||||||
permissions:
|
permissions:
|
||||||
vendor_acme: rwcd
|
vendor_acme: rwcd
|
||||||
_doc_controller: rwcda
|
_doc_controller: rwcda
|
||||||
_company: ""
|
_company: ""
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(vendorDir, ".zddc"), vendorZ, 0o644); err != nil {
|
||||||
t.Fatalf("party .zddc: %v", err)
|
t.Fatalf("vendor .zddc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
@ -462,84 +462,84 @@ acl:
|
||||||
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
||||||
_, do, _ := rolePermissionsTestSetup(t)
|
_, do, _ := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Vendor PUTs into their incoming → 201.
|
// Vendor PUTs into their Incoming → 201.
|
||||||
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
rec := do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("PUT vendor → Incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Vendor overwrites the same file → 200 (rwcd has w).
|
// Vendor overwrites the same file → 200 (rwcd has w).
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
rec = do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
t.Fatalf("PUT vendor → Incoming overwrite: want 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
|
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Seed an existing issued file.
|
// Seed an existing Issued file.
|
||||||
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor cannot overwrite — ancestor grant masked to r in issued.
|
// Vendor cannot overwrite — ancestor grant masked to r in Issued.
|
||||||
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
|
rec := do(http.MethodPut, "/Vendor/Issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("PUT vendor → Issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Vendor cannot delete.
|
// Vendor cannot delete.
|
||||||
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
|
rec = do(http.MethodDelete, "/Vendor/Issued/spec.pdf", "rep@acme.com", nil, nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code)
|
t.Fatalf("DELETE vendor → Issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
// Vendor cannot create new files — they have no explicit .zddc grant
|
// Vendor cannot create new files — they have no explicit .zddc grant
|
||||||
// at the issued folder, so the WORM split reduces their inherited
|
// at the Issued folder, so the WORM split reduces their inherited
|
||||||
// rwcd to r-only.
|
// rwcd to r-only.
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
|
rec = do(http.MethodPut, "/Vendor/Issued/new.pdf", "rep@acme.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code)
|
t.Fatalf("PUT vendor → Issued (create): want 403 (no explicit grant at Issued), got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
|
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
|
// Without a .zddc at Vendor/Issued/ explicitly granting cr, the dc's
|
||||||
// the dc's inherited rwcda is masked to r. They cannot create.
|
// inherited rwcda is masked to r. They cannot create.
|
||||||
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
|
rec := do(http.MethodPut, "/Vendor/Issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("dc without explicit grant → Issued: want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operator places an explicit grant at archive/Acme/issued/.zddc.
|
// Operator places an explicit grant at Vendor/Issued/.zddc. Now dc
|
||||||
// Now dc has cr at-or-below the WORM folder, which survives the mask.
|
// has cr at-or-below the WORM folder, which survives the mask.
|
||||||
issuedZ := []byte(`acl:
|
issuedZ := []byte(`acl:
|
||||||
permissions:
|
permissions:
|
||||||
_doc_controller: cr
|
_doc_controller: cr
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
|
||||||
t.Fatalf("write issued .zddc: %v", err)
|
t.Fatalf("write Issued .zddc: %v", err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
|
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("dc with explicit grant → Issued: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
|
got, _ := os.ReadFile(filepath.Join(root, "Vendor/Issued/2026-Q2-spec.pdf"))
|
||||||
if string(got) != "CONTROLLED" {
|
if string(got) != "CONTROLLED" {
|
||||||
t.Fatalf("body: %q", got)
|
t.Fatalf("body: %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dc still cannot overwrite — explicit grant is cr, no w.
|
// dc still cannot overwrite — explicit grant is cr, no w.
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
|
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code)
|
t.Fatalf("dc PUT overwrite → Issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
// dc still cannot delete.
|
// dc still cannot delete.
|
||||||
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
|
rec = do(http.MethodDelete, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code)
|
t.Fatalf("dc DELETE → Issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,29 +554,29 @@ func TestFileAPI_WORM_AdminBypass(t *testing.T) {
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(cfg.Root)
|
zddc.InvalidateCache(cfg.Root)
|
||||||
|
|
||||||
// Seed an issued file and have root@ delete it (escape hatch).
|
// Seed an Issued file and have root@ delete it (escape hatch).
|
||||||
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
|
rec := do(http.MethodDelete, "/Vendor/Issued/mistake.pdf", "root@example.com", nil, nil)
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("admin DELETE → Issued: want 204, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Vendor creates a folder under their incoming. Server should
|
// Vendor creates a folder under their Incoming. Server should
|
||||||
// auto-write a .zddc granting them rwcda on the new subtree.
|
// auto-write a .zddc granting them rwcda on the new subtree.
|
||||||
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Vendor/Incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
|
autoZ := filepath.Join(root, "Vendor/Incoming/2026-05-15-issue/.zddc")
|
||||||
data, err := os.ReadFile(autoZ)
|
data, err := os.ReadFile(autoZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("auto .zddc not written: %v", err)
|
t.Fatalf("auto .zddc not written: %v", err)
|
||||||
|
|
@ -595,7 +595,7 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
// now PUT a brand-new file inside their owned folder where they
|
// now PUT a brand-new file inside their owned folder where they
|
||||||
// otherwise wouldn't have ACL admin rights.
|
// otherwise wouldn't have ACL admin rights.
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
rec = do(http.MethodPut, "/Vendor/Incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -604,25 +604,25 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Place an explicit grant so dc has cr at the issued level.
|
// Place an explicit grant so dc has cr at the Issued level.
|
||||||
issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n")
|
issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n")
|
||||||
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
|
||||||
t.Fatalf("seed issued .zddc: %v", err)
|
t.Fatalf("seed Issued .zddc: %v", err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
// Doc controller mkdir under issued — should succeed (cr survives mask)
|
// Doc controller mkdir under Issued — should succeed (cr survives mask)
|
||||||
// but should NOT auto-write an ownership .zddc (issued is excluded
|
// but should NOT auto-write an ownership .zddc (Issued is excluded
|
||||||
// from auto-own).
|
// from auto-own).
|
||||||
rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Vendor/Issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
|
autoZ := filepath.Join(root, "Vendor/Issued/2026-Q2/.zddc")
|
||||||
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
|
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
|
||||||
t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err)
|
t.Errorf("auto .zddc should NOT be written under Issued; got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,153 +657,10 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
|
// Vendor's leaf rwcd grant in Vendor/.zddc is overridden by the
|
||||||
// the root deny under strict mode.
|
// root deny under strict mode.
|
||||||
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
|
rec := doStrict(http.MethodPut, "/Vendor/Incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- staging↔working mirror -------------------------------------------------
|
|
||||||
|
|
||||||
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
|
|
||||||
// name with spaces and parens, mirroring how a real client would encode it.
|
|
||||||
func stagingMirrorURL(project, folder string) string {
|
|
||||||
return "/" + project + "/staging/" + url.PathEscape(folder) + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_TRN(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans"
|
|
||||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("staging mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Staging side exists with auto-own.
|
|
||||||
stagingDir := filepath.Join(root, "Proj/staging", folder)
|
|
||||||
if info, err := os.Stat(stagingDir); err != nil || !info.IsDir() {
|
|
||||||
t.Fatalf("staging folder not created: err=%v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(stagingDir, ".zddc")); err != nil {
|
|
||||||
t.Errorf("staging auto-own .zddc missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Working mirror exists with auto-own.
|
|
||||||
workingDir := filepath.Join(root, "Proj/working", folder)
|
|
||||||
if info, err := os.Stat(workingDir); err != nil || !info.IsDir() {
|
|
||||||
t.Fatalf("working mirror not created: err=%v", err)
|
|
||||||
}
|
|
||||||
mirrorZ, err := os.ReadFile(filepath.Join(workingDir, ".zddc"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("working mirror auto-own .zddc missing: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(mirrorZ), "alice@example.com: rwcda") {
|
|
||||||
t.Errorf("mirror .zddc missing creator grant: %s", mirrorZ)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_SUB(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
folder := "2026-07-01_vendor-EM-SUB-0017 (RSA) - Review Notes"
|
|
||||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); err != nil {
|
|
||||||
t.Errorf("SUB-tracked folder should mirror; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_NonTransmittalNameSkipped(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
rec := do(http.MethodPost, "/Proj/staging/scratch/", "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
// staging/scratch/ exists.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj/staging/scratch")); err != nil {
|
|
||||||
t.Fatalf("staging/scratch not created: %v", err)
|
|
||||||
}
|
|
||||||
// No working/ sibling — name doesn't parse as transmittal.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj/working/scratch")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("non-transmittal name must NOT mirror; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_MdlTrackingSkipped(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
folder := "2026-06-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List"
|
|
||||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
// MDL deliverables are tracked in archive/<party>/mdl/, not via the
|
|
||||||
// working↔staging pairing — no mirror.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("-MDL- tracking must NOT mirror; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_DeepPathSkipped(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
// mkdir of staging/<name>/sub/ (depth 4) — only depth-3 (immediate
|
|
||||||
// child of staging/) qualifies for mirroring.
|
|
||||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - x"
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj/staging", folder), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rec := do(http.MethodPost, "/Proj/staging/"+url.PathEscape(folder)+"/sub/", "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("deep mkdir: got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
// The transmittal folder did not get a mirror retroactively because
|
|
||||||
// the mirror only fires on depth-3 mkdirs.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("deep mkdir should not retroactively mirror; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileAPI_StagingMirror_Idempotent(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
||||||
|
|
||||||
// Pre-create the working sibling with a sentinel file so we can detect
|
|
||||||
// if the mirror code blew it away.
|
|
||||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - existing"
|
|
||||||
mirrorDir := filepath.Join(root, "Proj/working", folder)
|
|
||||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sentinel := filepath.Join(mirrorDir, "preexisting.md")
|
|
||||||
if err := os.WriteFile(sentinel, []byte("user content"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
// Sentinel still exists — mirror was idempotent (no-op when sibling
|
|
||||||
// already present).
|
|
||||||
if _, err := os.Stat(sentinel); err != nil {
|
|
||||||
t.Errorf("idempotency: pre-existing content gone: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -41,66 +41,6 @@ import (
|
||||||
//go:embed tables.html
|
//go:embed tables.html
|
||||||
var embeddedTablesHTML []byte
|
var embeddedTablesHTML []byte
|
||||||
|
|
||||||
//go:embed default-mdl.table.yaml
|
|
||||||
var embeddedDefaultMdlTable []byte
|
|
||||||
|
|
||||||
//go:embed default-mdl.form.yaml
|
|
||||||
var embeddedDefaultMdlForm []byte
|
|
||||||
|
|
||||||
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
|
|
||||||
// Used by the static-file handler to serve the default spec at
|
|
||||||
// archive/<party>/mdl.table.yaml when no operator file exists on disk.
|
|
||||||
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
|
|
||||||
|
|
||||||
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
|
||||||
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
|
||||||
|
|
||||||
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request
|
|
||||||
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/
|
|
||||||
// where no operator file exists. Caller is the static-file handler.
|
|
||||||
//
|
|
||||||
// Returns the embedded bytes + true when the fallback should fire.
|
|
||||||
// Returns nil + false when an operator-supplied file exists or the path
|
|
||||||
// is not eligible for the fallback.
|
|
||||||
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
|
||||||
base := strings.ToLower(filepath.Base(urlPath))
|
|
||||||
var bytes []byte
|
|
||||||
switch base {
|
|
||||||
case "mdl.table.yaml":
|
|
||||||
bytes = embeddedDefaultMdlTable
|
|
||||||
case "mdl.form.yaml":
|
|
||||||
bytes = embeddedDefaultMdlForm
|
|
||||||
default:
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if !isAtArchivePartyLevel(fsRoot, urlPath) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
// Operator file wins if it exists on disk.
|
|
||||||
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
|
|
||||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
|
||||||
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if fileExists(abs) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return bytes, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
|
||||||
// directly under <project>/archive/<party>/ (depth-3 directory). The
|
|
||||||
// canonical-folder names are case-folded.
|
|
||||||
func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
|
|
||||||
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
// <project>/archive/<party>/<file> = 4 segments
|
|
||||||
if len(parts) != 4 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(parts[1], "archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableRequest describes a recognized table-system request.
|
// TableRequest describes a recognized table-system request.
|
||||||
type TableRequest struct {
|
type TableRequest struct {
|
||||||
// Name is the table's URL stem (the key declared in .zddc tables).
|
// Name is the table's URL stem (the key declared in .zddc tables).
|
||||||
|
|
@ -146,17 +86,16 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
}
|
}
|
||||||
zddcPath := filepath.Join(dirAbs, ".zddc")
|
zddcPath := filepath.Join(dirAbs, ".zddc")
|
||||||
zf, err := zddc.ParseFile(zddcPath)
|
zf, err := zddc.ParseFile(zddcPath)
|
||||||
if err != nil && !isNotExistError(err) {
|
if err != nil {
|
||||||
// Malformed .zddc — log and pass through; static handler will 500
|
// Malformed .zddc — log and pass through; static handler will 500
|
||||||
// if it cares. Recognition just says "not a declared table here."
|
// if it cares. Recognition just says "not a declared table here."
|
||||||
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
|
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if specRel, ok := zf.Tables[name]; ok {
|
specRel, ok := zf.Tables[name]
|
||||||
// Operator explicitly declared this table — honour it strictly.
|
if !ok {
|
||||||
// If the declared spec file is missing, return nil so the URL
|
return nil
|
||||||
// 404s rather than silently falling back to the default. This
|
}
|
||||||
// keeps a typo in the operator's .zddc visible.
|
|
||||||
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
|
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
|
||||||
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -170,42 +109,6 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
Dir: dirAbs,
|
Dir: dirAbs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No operator declaration — apply the default MDL spec fallback at
|
|
||||||
// archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
|
|
||||||
// the static-file dispatcher.
|
|
||||||
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
|
|
||||||
return &TableRequest{
|
|
||||||
Name: "mdl",
|
|
||||||
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
|
|
||||||
Dir: dirAbs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNotExistError reports whether err indicates a missing file. Local
|
|
||||||
// helper to avoid pulling errors.Is into the handler package.
|
|
||||||
func isNotExistError(err error) bool {
|
|
||||||
return err != nil && strings.Contains(err.Error(), "no such file or directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isArchivePartyDir reports whether dirAbs is a <project>/archive/<party>/
|
|
||||||
// directory under fsRoot, with archive case-folded.
|
|
||||||
func isArchivePartyDir(fsRoot, dirAbs string) bool {
|
|
||||||
rel, err := filepath.Rel(fsRoot, dirAbs)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(parts[1], "archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeTable serves the static tables.html bytes for a recognized
|
// ServeTable serves the static tables.html bytes for a recognized
|
||||||
// request. ACL gate is the read action at the request directory; on
|
// request. ACL gate is the read action at the request directory; on
|
||||||
|
|
|
||||||
|
|
@ -227,144 +227,3 @@ tables:
|
||||||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- default MDL spec fallback ---------------------------------------------
|
|
||||||
|
|
||||||
// archivePartyTestSetup builds a minimal Project/archive/<party>/ tree
|
|
||||||
// with no operator-supplied tables: declaration. RecognizeTableRequest
|
|
||||||
// should still fire for "mdl" thanks to the default-spec fallback.
|
|
||||||
func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
partyDir := filepath.Join(root, "Project", "archive", "Acme")
|
|
||||||
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if partyZddcExtras != "" {
|
|
||||||
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
cfg := config.Config{
|
|
||||||
Root: root,
|
|
||||||
EmailHeader: "X-Auth-Request-Email",
|
|
||||||
}
|
|
||||||
do := func(method, target, email string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
|
|
||||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
|
|
||||||
tr := RecognizeTableRequest(cfg.Root, method, target)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
if tr == nil {
|
|
||||||
rec.WriteHeader(http.StatusNotFound)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
ServeTable(cfg, tr, rec, req)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
return root, do
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
|
||||||
_, do := archivePartyTestSetup(t, "")
|
|
||||||
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "<html") {
|
|
||||||
t.Errorf("expected tables.html bytes, got %q…", body[:min(80, len(body))])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecognizeTableRequest_OperatorOverrideWins(t *testing.T) {
|
|
||||||
// Operator declared a custom mdl spec that points at a non-existent
|
|
||||||
// file. The fallback should NOT fire, because the operator
|
|
||||||
// explicitly took control. RecognizeTableRequest returns nil.
|
|
||||||
root, do := archivePartyTestSetup(t, `tables:
|
|
||||||
mdl: ./custom-mdl.table.yaml
|
|
||||||
`)
|
|
||||||
_ = root
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("operator declaration with missing spec should fall through to 404, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
|
||||||
// Default fallback is scoped to <project>/archive/<party>/. A
|
|
||||||
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
|
|
||||||
// non-archive path should return nil (no recognition).
|
|
||||||
_, do := archivePartyTestSetup(t, "")
|
|
||||||
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
|
|
||||||
}
|
|
||||||
rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// archive/Acme/ exists but no mdl.table.yaml on disk.
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml")
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected fallback to fire")
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(bts), "Master Deliverables List") {
|
|
||||||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
|
||||||
}
|
|
||||||
|
|
||||||
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml")
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected form fallback to fire")
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(bts), "Deliverable") {
|
|
||||||
t.Errorf("default form spec missing expected title")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
dir := filepath.Join(root, "Project", "archive", "Acme")
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok {
|
|
||||||
t.Errorf("operator file should win over embedded fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
cases := []string{
|
|
||||||
"/Project/working/mdl.table.yaml",
|
|
||||||
"/Project/archive/mdl.table.yaml", // depth 3 — no party segment
|
|
||||||
"/Project/archive/Acme/sub/mdl.table.yaml",
|
|
||||||
}
|
|
||||||
for _, p := range cases {
|
|
||||||
if _, ok := IsDefaultMdlSpec(root, p); ok {
|
|
||||||
t.Errorf("path %q should NOT trigger default fallback", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ package listing
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// FileInfo matches Caddy's browse JSON output exactly (with one ZDDC-
|
// FileInfo matches Caddy's browse JSON output exactly.
|
||||||
// specific extension: Virtual). The archive browser (source.js) expects
|
// The archive browser (source.js) expects this exact shape.
|
||||||
// this exact shape.
|
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
Name string `json:"name"` // filename; directories have a trailing "/"
|
Name string `json:"name"` // filename; directories have a trailing "/"
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
|
@ -13,12 +12,4 @@ type FileInfo struct {
|
||||||
Mode uint32 `json:"mode"`
|
Mode uint32 `json:"mode"`
|
||||||
IsDir bool `json:"is_dir"`
|
IsDir bool `json:"is_dir"`
|
||||||
IsSymlink bool `json:"is_symlink"` // always false — no real symlinks served
|
IsSymlink bool `json:"is_symlink"` // always false — no real symlinks served
|
||||||
|
|
||||||
// Virtual marks an entry that doesn't exist on disk yet but is
|
|
||||||
// surfaced in listings as a synthetic affordance — e.g. the per-user
|
|
||||||
// <viewer-email>/ entry under working/. A first write to a virtual
|
|
||||||
// path materialises a real folder (with auto-own .zddc); subsequent
|
|
||||||
// listings drop the synthetic entry. Clients can use this flag to
|
|
||||||
// render the entry differently (placeholder badge, drop-target hint).
|
|
||||||
Virtual bool `json:"virtual,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResolveCanonicalPath substitutes on-disk casing for any canonical
|
|
||||||
// ancestor segment of target, without creating anything. Returns target
|
|
||||||
// unchanged when no case variant exists or when target is at fsRoot or
|
|
||||||
// outside it.
|
|
||||||
//
|
|
||||||
// Use this before authorization to make ACL lookups operate against the
|
|
||||||
// real on-disk path without side effects. EnsureCanonicalAncestors
|
|
||||||
// performs the same substitution AND creates missing ancestors —
|
|
||||||
// authorization should run between the two.
|
|
||||||
func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|
||||||
rel, err := filepath.Rel(fsRoot, target)
|
|
||||||
if err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
||||||
return target, nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
resolvedSegs := make([]string, len(parts))
|
|
||||||
copy(resolvedSegs, parts)
|
|
||||||
|
|
||||||
join := func(n int) string {
|
|
||||||
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
|
|
||||||
return filepath.Join(segs...)
|
|
||||||
}
|
|
||||||
resolveAt := func(n int, logical string) error {
|
|
||||||
parent := join(n)
|
|
||||||
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
actual, err := ResolveCanonical(parent, logical)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if actual != "" {
|
|
||||||
resolvedSegs[n] = actual
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
seg := strings.ToLower(parts[1])
|
|
||||||
if seg == "archive" || seg == "working" || seg == "staging" {
|
|
||||||
if err := resolveAt(1, seg); err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
|
||||||
seg := strings.ToLower(parts[3])
|
|
||||||
switch seg {
|
|
||||||
case "mdl", "incoming", "received", "issued":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
|
||||||
// creating any missing canonical-folder ancestor with MkdirAll(perm). For
|
|
||||||
// freshly-created auto-own ancestors (working/, staging/, or
|
|
||||||
// archive/<party>/incoming/), it also writes a creator-owned .zddc using
|
|
||||||
// principalEmail (skipped if principalEmail is empty).
|
|
||||||
//
|
|
||||||
// Returns the resolved version of target with on-disk casing substituted
|
|
||||||
// for any canonical ancestor whose disk variant differs from the requested
|
|
||||||
// casing — so a pre-existing Working/ is reused rather than shadowed by a
|
|
||||||
// new working/ sibling. The basename of target is never altered.
|
|
||||||
//
|
|
||||||
// Canonical positions, relative to fsRoot:
|
|
||||||
//
|
|
||||||
// - <project>/<canonical-root> where <canonical-root> ∈
|
|
||||||
// {archive, working, staging}
|
|
||||||
// - <project>/archive/<party>/<canonical-party> where
|
|
||||||
// <canonical-party> ∈ {mdl, incoming, received, issued}
|
|
||||||
//
|
|
||||||
// "reviewing" is intentionally NOT created here — it's a purely virtual
|
|
||||||
// route. A write that targets a path under <project>/reviewing/ returns
|
|
||||||
// an error (callers should reject before invoking this helper).
|
|
||||||
//
|
|
||||||
// fsRoot and target must be absolute filesystem paths under the same
|
|
||||||
// volume; target may not yet exist on disk.
|
|
||||||
func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.FileMode) (string, error) {
|
|
||||||
rel, err := filepath.Rel(fsRoot, target)
|
|
||||||
if err != nil {
|
|
||||||
return target, fmt.Errorf("rel: %w", err)
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
||||||
return target, fmt.Errorf("target %q escapes fsRoot %q", target, fsRoot)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
// target is at fsRoot/<single-segment>; no canonical ancestors apply.
|
|
||||||
return target, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject writes under reviewing/ — virtual route.
|
|
||||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") {
|
|
||||||
return target, fmt.Errorf("reviewing/ is virtual and not writable")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedSegs := make([]string, len(parts))
|
|
||||||
copy(resolvedSegs, parts)
|
|
||||||
|
|
||||||
// Track which ancestor directories we end up creating so we can seed
|
|
||||||
// auto-own .zddc files on the right ones afterwards.
|
|
||||||
type created struct {
|
|
||||||
absPath string
|
|
||||||
autoOwn bool
|
|
||||||
}
|
|
||||||
var freshlyCreated []created
|
|
||||||
|
|
||||||
// joinUnder builds an absolute path from fsRoot + the first n resolved
|
|
||||||
// segments.
|
|
||||||
joinUnder := func(n int) string {
|
|
||||||
segs := append([]string{fsRoot}, resolvedSegs[:n]...)
|
|
||||||
return filepath.Join(segs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAt(n) tries to use the on-disk casing for resolvedSegs[n] inside
|
|
||||||
// joinUnder(n), substituting if a case-variant directory exists.
|
|
||||||
resolveAt := func(n int, logical string) error {
|
|
||||||
parent := joinUnder(n)
|
|
||||||
// Only substitute if the requested segment doesn't already match
|
|
||||||
// on disk (cheap optimisation to avoid a ReadDir on the hot path).
|
|
||||||
if _, err := os.Stat(filepath.Join(parent, resolvedSegs[n])); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
actual, err := ResolveCanonical(parent, logical)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if actual != "" {
|
|
||||||
resolvedSegs[n] = actual
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
|
||||||
// Depth 0 is the project segment; not a canonical name.
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
// Depth 1 candidate: archive / working / staging.
|
|
||||||
seg := strings.ToLower(parts[1])
|
|
||||||
if seg == "archive" || seg == "working" || seg == "staging" {
|
|
||||||
if err := resolveAt(1, seg); err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Depth 3 candidate (archive/<party>/<canonical-party>): mdl / incoming /
|
|
||||||
// received / issued. Only meaningful when depth 1 is "archive".
|
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
|
||||||
seg := strings.ToLower(parts[3])
|
|
||||||
switch seg {
|
|
||||||
case "mdl", "incoming", "received", "issued":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now create any missing ancestors. We MkdirAll up to (but not
|
|
||||||
// including) the basename. The handler's actual write call still does
|
|
||||||
// its own parent-dir creation; this is the proactive seeding pass.
|
|
||||||
parentDir := filepath.Dir(filepath.Join(append([]string{fsRoot}, resolvedSegs...)...))
|
|
||||||
rootRel, _ := filepath.Rel(fsRoot, parentDir)
|
|
||||||
rootRel = filepath.ToSlash(rootRel)
|
|
||||||
if rootRel == "." || strings.HasPrefix(rootRel, "../") {
|
|
||||||
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk segment-by-segment. For each ancestor that doesn't exist yet,
|
|
||||||
// create it and record whether the position is auto-own.
|
|
||||||
pathSoFar := fsRoot
|
|
||||||
parentSegs := strings.Split(rootRel, "/")
|
|
||||||
for i, name := range parentSegs {
|
|
||||||
pathSoFar = filepath.Join(pathSoFar, name)
|
|
||||||
if info, err := os.Stat(pathSoFar); err == nil {
|
|
||||||
if !info.IsDir() {
|
|
||||||
return target, fmt.Errorf("ancestor %q exists but is not a directory", pathSoFar)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(pathSoFar, perm); err != nil {
|
|
||||||
return target, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this newly-created ancestor is an auto-own position.
|
|
||||||
// 'i' is the index into parentSegs which corresponds to depth i+1
|
|
||||||
// from fsRoot (parentSegs[0] is the project segment).
|
|
||||||
freshlyCreated = append(freshlyCreated, created{
|
|
||||||
absPath: pathSoFar,
|
|
||||||
autoOwn: isAutoOwnDepthMatch(parentSegs, i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed auto-own .zddc on the canonical positions that were freshly
|
|
||||||
// created. Skip if no principal email is available (anonymous or
|
|
||||||
// system writes).
|
|
||||||
if principalEmail != "" {
|
|
||||||
for _, c := range freshlyCreated {
|
|
||||||
if !c.autoOwn {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := WriteAutoOwnZddc(c.absPath, principalEmail); err != nil {
|
|
||||||
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAutoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical
|
|
||||||
// auto-own depth. parentSegs is the slash-relative path from project root
|
|
||||||
// onward (so parentSegs[0] is the project segment).
|
|
||||||
func isAutoOwnDepthMatch(parentSegs []string, idx int) bool {
|
|
||||||
switch idx {
|
|
||||||
case 1:
|
|
||||||
// <project>/working or <project>/staging
|
|
||||||
return strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging")
|
|
||||||
case 3:
|
|
||||||
// <project>/archive/<party>/incoming
|
|
||||||
return strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming")
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md")
|
|
||||||
|
|
||||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure: %v", err)
|
|
||||||
}
|
|
||||||
if resolved != target {
|
|
||||||
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// working/ is now created with auto-own .zddc.
|
|
||||||
autoZ := filepath.Join(root, "Proj", "working", ".zddc")
|
|
||||||
data, err := os.ReadFile(autoZ)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
|
||||||
}
|
|
||||||
body := string(data)
|
|
||||||
if !strings.Contains(body, "alice@x.com: rwcda") {
|
|
||||||
t.Errorf("auto-own grant missing: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "created_by: alice@x.com") {
|
|
||||||
t.Errorf("created_by missing: %s", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// alice@x.com/ subfolder also exists, no auto-own (it's not a canonical
|
|
||||||
// position — it's a regular subdir under working/).
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
|
|
||||||
t.Errorf("subfolder not created: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("subfolder should NOT have auto-own .zddc; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// Pre-create Working/ (PascalCase).
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := filepath.Join(root, "Proj", "working", "foo.md")
|
|
||||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolved path uses on-disk Working/ casing.
|
|
||||||
want := filepath.Join(root, "Proj", "Working", "foo.md")
|
|
||||||
if resolved != want {
|
|
||||||
t.Errorf("resolved=%q, want %q", resolved, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No new working/ sibling.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Working/ already existed before our call — no auto-own .zddc was
|
|
||||||
// retroactively written.
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
target := filepath.Join(root, "Proj", "archive", "ACME", "incoming", "submission.pdf")
|
|
||||||
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// archive/ created (no auto-own).
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
|
|
||||||
t.Errorf("archive/ not created: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// archive/ACME/ created (no auto-own — it's a party folder, not canonical).
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME")); err != nil {
|
|
||||||
t.Errorf("ACME/ not created: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("ACME/ should not have auto-own .zddc; got err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// archive/ACME/incoming/ created WITH auto-own.
|
|
||||||
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "incoming", ".zddc")
|
|
||||||
data, err := os.ReadFile(autoZ)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("auto-own .zddc at incoming/ not written: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), "rep@acme.com: rwcda") {
|
|
||||||
t.Errorf("incoming/ auto-own missing rep grant: %s", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
target := filepath.Join(root, "Proj", "archive", "ACME", "issued", "spec.pdf")
|
|
||||||
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued")); err != nil {
|
|
||||||
t.Errorf("issued/ not created: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("issued/ should NOT have auto-own .zddc (WORM); got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
target := filepath.Join(root, "Proj", "working", "anon.md")
|
|
||||||
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
|
||||||
t.Errorf("working/ not created: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
target := filepath.Join(root, "Proj", "reviewing", "x.md")
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected error for write under reviewing/, got nil")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
other := t.TempDir()
|
|
||||||
target := filepath.Join(other, "evil.md")
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected error for target outside fsRoot")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// transmittalFolderRE matches the canonical ZDDC transmittal-folder shape:
|
|
||||||
//
|
|
||||||
// YYYY-MM-DD_<tracking> (<status>) - <title>
|
|
||||||
//
|
|
||||||
// where <tracking> has no spaces or underscores, <status> is anything inside
|
|
||||||
// parentheses, and <title> is anything after the dash. Capture groups:
|
|
||||||
//
|
|
||||||
// 1: date (YYYY-MM-DD)
|
|
||||||
// 2: tracking number (e.g. proj-EM-TRN-0042)
|
|
||||||
// 3: status (e.g. IFR, IFA, RSA, DFT)
|
|
||||||
// 4: title
|
|
||||||
var transmittalFolderRE = regexp.MustCompile(
|
|
||||||
`^(\d{4}-\d{2}-\d{2})_([^_\s]+(?:-[^_\s]+)*)\s*\(([^)]+)\)\s*-\s*(.+)$`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseTransmittalFolder splits a folder basename into its ZDDC transmittal
|
|
||||||
// components. The fourth return is true iff name is a well-formed transmittal
|
|
||||||
// folder. Trailing slashes on name are tolerated.
|
|
||||||
//
|
|
||||||
// Used by handlers that need to recognise a staging-folder mkdir as a
|
|
||||||
// transmittal envelope (to mirror it under working/), and by the archive
|
|
||||||
// indexer that scans for transmittal folders on disk.
|
|
||||||
func ParseTransmittalFolder(name string) (date, tracking, status, title string, ok bool) {
|
|
||||||
name = strings.TrimRight(name, "/")
|
|
||||||
m := transmittalFolderRE.FindStringSubmatch(name)
|
|
||||||
if m == nil {
|
|
||||||
return "", "", "", "", false
|
|
||||||
}
|
|
||||||
return m[1], m[2], m[3], m[4], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTrnOrSubTracking reports whether tracking contains a "-TRN-" or "-SUB-"
|
|
||||||
// segment (case-insensitive). These two tracking types are the ones whose
|
|
||||||
// staging folders get a paired drafting folder under working/.
|
|
||||||
//
|
|
||||||
// "-MDL-" and other tracking types do NOT match — MDL deliverables are
|
|
||||||
// tracked via per-party mdl/ rows, not via the working↔staging mirror.
|
|
||||||
func IsTrnOrSubTracking(tracking string) bool {
|
|
||||||
upper := strings.ToUpper(tracking)
|
|
||||||
return strings.Contains(upper, "-TRN-") || strings.Contains(upper, "-SUB-")
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseTransmittalFolder(t *testing.T) {
|
|
||||||
type expect struct {
|
|
||||||
date, tracking, status, title string
|
|
||||||
ok bool
|
|
||||||
}
|
|
||||||
cases := map[string]expect{
|
|
||||||
"2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans": {
|
|
||||||
date: "2026-06-15", tracking: "proj-EM-TRN-0042", status: "DFT", title: "Foundation Plans", ok: true,
|
|
||||||
},
|
|
||||||
"2026-01-15_123456-EM-SUB-0001 (IFR) - Submittal Title.with.dots": {
|
|
||||||
date: "2026-01-15", tracking: "123456-EM-SUB-0001", status: "IFR", title: "Submittal Title.with.dots", ok: true,
|
|
||||||
},
|
|
||||||
"2026-01-15_proj-EM-SUB-0001 (RSA) - Response Notes": {
|
|
||||||
date: "2026-01-15", tracking: "proj-EM-SUB-0001", status: "RSA", title: "Response Notes", ok: true,
|
|
||||||
},
|
|
||||||
// Whitespace variations around "(" and "-".
|
|
||||||
"2026-01-15_proj-EM-TRN-1(IFC)-Title": {
|
|
||||||
date: "2026-01-15", tracking: "proj-EM-TRN-1", status: "IFC", title: "Title", ok: true,
|
|
||||||
},
|
|
||||||
// MDL is a valid transmittal name even though it doesn't match TRN/SUB.
|
|
||||||
"2026-01-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List": {
|
|
||||||
date: "2026-01-15", tracking: "proj-EM-MDL-0001", status: "IFR", title: "Master Deliverables List", ok: true,
|
|
||||||
},
|
|
||||||
// Negatives.
|
|
||||||
"": {ok: false},
|
|
||||||
"scratch": {ok: false},
|
|
||||||
"2026-06-15 missing-underscore": {ok: false},
|
|
||||||
"NotADate_proj-EM-TRN-0042 (DFT) - Title": {ok: false},
|
|
||||||
"2026-06-15_no-status - Title": {ok: false},
|
|
||||||
"2026-06-15_no-dash (IFC) Title": {ok: false},
|
|
||||||
// Tracking with whitespace must not parse — tracking has no spaces.
|
|
||||||
"2026-06-15_proj EM TRN (IFC) - Title": {ok: false},
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
date, tracking, status, title, ok := ParseTransmittalFolder(in)
|
|
||||||
if ok != want.ok {
|
|
||||||
t.Errorf("ParseTransmittalFolder(%q) ok=%v, want %v", in, ok, want.ok)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if date != want.date || tracking != want.tracking || status != want.status || title != want.title {
|
|
||||||
t.Errorf("ParseTransmittalFolder(%q) = (%q,%q,%q,%q), want (%q,%q,%q,%q)",
|
|
||||||
in, date, tracking, status, title, want.date, want.tracking, want.status, want.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTransmittalFolderTrailingSlash(t *testing.T) {
|
|
||||||
date, tracking, _, _, ok := ParseTransmittalFolder("2026-06-15_proj-TRN-1 (DFT) - x/")
|
|
||||||
if !ok || date != "2026-06-15" || tracking != "proj-TRN-1" {
|
|
||||||
t.Errorf("trailing slash not tolerated: ok=%v date=%q tracking=%q", ok, date, tracking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsTrnOrSubTracking(t *testing.T) {
|
|
||||||
yes := []string{
|
|
||||||
"proj-EM-TRN-0042",
|
|
||||||
"proj-EM-SUB-0001",
|
|
||||||
"PROJ-em-trn-0001", // case-fold
|
|
||||||
"vendor-sub-9",
|
|
||||||
"a-TRN-b-SUB-c", // either qualifies
|
|
||||||
}
|
|
||||||
no := []string{
|
|
||||||
"",
|
|
||||||
"proj-EM-MDL-0001",
|
|
||||||
"trn-without-dashes",
|
|
||||||
"sub-without-dashes",
|
|
||||||
"prefixTRN", // no dashes around TRN
|
|
||||||
"-TRN", // missing trailing dash
|
|
||||||
"TRN-", // missing leading dash
|
|
||||||
"proj-TRNX-001", // not exactly -TRN-
|
|
||||||
}
|
|
||||||
for _, s := range yes {
|
|
||||||
if !IsTrnOrSubTracking(s) {
|
|
||||||
t.Errorf("IsTrnOrSubTracking(%q) = false, want true", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range no {
|
|
||||||
if IsTrnOrSubTracking(s) {
|
|
||||||
t.Errorf("IsTrnOrSubTracking(%q) = true, want false", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +1,79 @@
|
||||||
package zddc
|
package zddc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectRootFolders are the canonical lowercase folder names that may
|
// SpecialFolderNames is the canonical list of folder names that drive
|
||||||
// appear directly under a project root. The server resolves them
|
// per-tool availability rules and post-cascade access-decision behaviors.
|
||||||
// case-insensitively on disk: a manually-created Working/ is reused
|
// Centralized here so apps/availability and the access-control evaluator
|
||||||
// rather than shadowed by a new working/.
|
// share one source of truth.
|
||||||
//
|
//
|
||||||
// - "archive" — formal record of issued/received transmittals,
|
// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator
|
||||||
// organised by counterparty (and ourselves)
|
// becomes the new subtree's admin).
|
||||||
// - "working" — user-owned drafting workspace
|
// - "Working" — internal pre-publication workspace; mkdir auto-ownership.
|
||||||
// - "staging" — outbound-transmittal preparation
|
// - "Staging" — outbound transmittal staging; mkdir auto-ownership.
|
||||||
// - "reviewing" — purely virtual cross-reference of in-progress
|
// - "Issued" — immutable archive of documents we sent out. WORM mask
|
||||||
// review responses (never written to disk)
|
// strips w/d/a from non-admin principals.
|
||||||
var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}
|
// - "Received" — immutable archive of documents we accepted. Same WORM
|
||||||
|
// semantics as Issued.
|
||||||
// PartyFolders are the canonical lowercase folder names that may appear
|
|
||||||
// directly under archive/<party>/, where <party> is a counterparty or
|
|
||||||
// the self-folder (we treat ourselves like any other third party).
|
|
||||||
//
|
//
|
||||||
// - "mdl" — yaml-per-deliverable metadata, edited via the
|
// Names are case-sensitive and exactly capitalized — operators name their
|
||||||
// table-editor app at <party>/mdl.table.html
|
// folders this way by convention. A folder spelled differently (e.g.
|
||||||
// - "incoming" — that party's drop point (we QC then promote)
|
// "incoming") is just a regular folder with no special semantics.
|
||||||
// - "received" — immutable record of incoming we've accepted (WORM)
|
var SpecialFolderNames = []string{
|
||||||
// - "issued" — immutable record of what we sent (WORM)
|
"Incoming",
|
||||||
var PartyFolders = []string{"mdl", "incoming", "received", "issued"}
|
"Working",
|
||||||
|
"Staging",
|
||||||
// AutoOwnCanonicalNames is the subset of canonical folder names where
|
"Issued",
|
||||||
// the file API's first-write hook auto-writes a creator-owned .zddc
|
"Received",
|
||||||
// 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"}
|
|
||||||
|
|
||||||
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
|
||||||
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
|
||||||
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
|
||||||
// ownership when a new auto-own folder is materialised.
|
|
||||||
//
|
|
||||||
// The grant is identical to what an operator would write by hand —
|
|
||||||
// direct email pattern, "rwcda" verb set — so the creator can later
|
|
||||||
// edit the file normally to add collaborators.
|
|
||||||
//
|
|
||||||
// Atomic: marshals via the same yaml encoder ParseFile reads
|
|
||||||
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
|
||||||
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
|
||||||
func WriteAutoOwnZddc(dir, principalEmail string) error {
|
|
||||||
zf := ZddcFile{
|
|
||||||
ACL: ACLRules{
|
|
||||||
Permissions: map[string]string{principalEmail: "rwcda"},
|
|
||||||
},
|
|
||||||
CreatedBy: principalEmail,
|
|
||||||
}
|
|
||||||
return WriteFile(dir, zf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveCanonical returns the on-disk name of the canonical folder
|
// AutoOwnFolderNames is the subset of SpecialFolderNames where the file
|
||||||
// 'logical' (lowercase) inside parentDir, or "" if no case variant
|
// API's mkdir post-hook auto-writes a creator-owned .zddc into the new
|
||||||
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
|
// subdirectory. Issued / Received are deliberately excluded — filing in
|
||||||
// when "" is returned.
|
// the immutable archive should not create owned subtrees inside it.
|
||||||
//
|
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
|
||||||
// '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
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAutoOwnPath reports whether parentDir is one of the canonical
|
// WormFolderNames is the subset of SpecialFolderNames covered by the
|
||||||
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
|
// post-cascade WORM mask. Any path whose chain crosses one of these
|
||||||
// inside such a directory should receive a creator-owned .zddc.
|
// names has w/d/a stripped from non-admin principals.
|
||||||
//
|
var WormFolderNames = []string{"Issued", "Received"}
|
||||||
// Canonical positions, relative to fsRoot:
|
|
||||||
//
|
|
||||||
// - <project>/working
|
|
||||||
// - <project>/staging
|
|
||||||
// - <project>/archive/<party>/incoming
|
|
||||||
//
|
|
||||||
// Segment matches are case-insensitive on canonical names. The project
|
|
||||||
// and party names are unrestricted.
|
|
||||||
//
|
|
||||||
// parentDir and fsRoot are filesystem paths. parentDir must be inside
|
|
||||||
// fsRoot; otherwise the function returns false.
|
|
||||||
func IsAutoOwnPath(parentDir, fsRoot string) bool {
|
|
||||||
rel, err := filepath.Rel(fsRoot, parentDir)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
switch len(parts) {
|
|
||||||
case 2:
|
|
||||||
// <project>/working or <project>/staging
|
|
||||||
return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging")
|
|
||||||
case 4:
|
|
||||||
// <project>/archive/<party>/incoming
|
|
||||||
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming")
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsWormPath reports whether requestPath crosses an
|
// IsAutoOwnParent reports whether a folder named name should trigger
|
||||||
// archive/<party>/received/ or archive/<party>/issued/ segment chain.
|
// the mkdir auto-ownership .zddc write when a child is created inside
|
||||||
// Pure path-segment check; case-fold on canonical names.
|
// it. Used by the file API's mkdir handler.
|
||||||
//
|
func IsAutoOwnParent(name string) bool {
|
||||||
// The party segment is unrestricted — any directory under archive/ is
|
for _, n := range AutoOwnFolderNames {
|
||||||
// treated as a party, including the self-folder. requestPath may be a
|
if name == n {
|
||||||
// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem
|
|
||||||
// path; only segment names matter.
|
|
||||||
func IsWormPath(requestPath string) bool {
|
|
||||||
parts := splitPathSegments(requestPath)
|
|
||||||
for i := 0; i+2 < len(parts); i++ {
|
|
||||||
if !strings.EqualFold(parts[i], "archive") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// parts[i+1] is the party name (anything).
|
|
||||||
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsWormPath reports whether requestPath is inside an "Issued" or
|
||||||
|
// "Received" subtree. The check is purely on path segments — a file
|
||||||
|
// named "Issued.txt" does not trigger WORM, but
|
||||||
|
// "/Project/Vendor/Issued/foo.pdf" does, as does
|
||||||
|
// "/Project/Vendor/Issued/" itself. requestPath may be a URL path
|
||||||
|
// ("/foo/bar") or a filesystem path; only segment names matter.
|
||||||
|
func IsWormPath(requestPath string) bool {
|
||||||
|
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
||||||
|
if clean == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(clean, "/") {
|
||||||
|
for _, name := range WormFolderNames {
|
||||||
|
if seg == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// WormMask reduces a verb set to the subset that survives the WORM
|
// WormMask reduces a verb set to the subset that survives the WORM
|
||||||
// constraint: the bitwise AND with VerbsRC. Removes w, d, and a.
|
// constraint: the bitwise AND with VerbsRC. Removes w, d, and a.
|
||||||
//
|
//
|
||||||
|
|
@ -168,52 +82,41 @@ func IsWormPath(requestPath string) bool {
|
||||||
// are the deliberate escape hatch for mis-filed documents.
|
// are the deliberate escape hatch for mis-filed documents.
|
||||||
//
|
//
|
||||||
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
||||||
// inherited from ancestors above the received/issued folder are
|
// inherited from ancestors above the Issued/Received folder are
|
||||||
// masked to read only ({r}), while grants at-or-below the WORM
|
// masked to read only ({r}), while grants at-or-below the WORM
|
||||||
// folder retain {r, c} so an operator can place a .zddc at the
|
// folder retain {r, c} so an operator can place a .zddc at the
|
||||||
// received/issued folder explicitly granting `_doc_controller: cr`.
|
// Issued folder explicitly granting `_doc_controller: cr`.
|
||||||
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
||||||
|
|
||||||
// WormFolderLevelIndex returns the chain index of the deepest
|
// WormFolderLevelIndex returns the chain index of the deepest
|
||||||
// archive/<party>/(received|issued) segment in requestPath. The chain
|
// "Issued" or "Received" segment in requestPath. The chain
|
||||||
// corresponds to the directory tree from root (index 0) to the
|
// corresponds to the directory tree from root (index 0) to the
|
||||||
// requested directory; level i is the .zddc at path segment depth i.
|
// requested directory; level i is the .zddc at path segment depth i.
|
||||||
//
|
//
|
||||||
// numLevels is len(chain.Levels); used to clamp results to the chain's
|
// numLevels is len(chain.Levels); used to clamp results to the
|
||||||
// actual range. URL segment i lives at chain index i+1 (root is chain
|
// chain's actual range (e.g. a request to a file inside an Issued
|
||||||
// index 0), so the WORM segment at parts[i+2] corresponds to chain
|
// folder has a chain that only covers up to the Issued directory,
|
||||||
// index i+3.
|
// not the file itself).
|
||||||
//
|
//
|
||||||
// Returns -1 if no WORM segment is in the request path or the computed
|
// Returns -1 if no WORM segment is in the request path or the
|
||||||
// index is out of range. The returned index satisfies
|
// computed index is out of range. The returned index satisfies
|
||||||
// 0 <= index < numLevels.
|
// 0 <= index < numLevels.
|
||||||
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
||||||
if numLevels <= 0 {
|
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
||||||
|
if clean == "" || numLevels <= 0 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
parts := splitPathSegments(requestPath)
|
|
||||||
deepest := -1
|
deepest := -1
|
||||||
for i := 0; i+2 < len(parts); i++ {
|
for i, seg := range strings.Split(clean, "/") {
|
||||||
if !strings.EqualFold(parts[i], "archive") {
|
for _, name := range WormFolderNames {
|
||||||
continue
|
if seg == name {
|
||||||
}
|
// URL segment i lives at chain index i+1 (root is index 0).
|
||||||
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
idx := i + 1
|
||||||
idx := i + 3
|
|
||||||
if idx < numLevels && idx > deepest {
|
if idx < numLevels && idx > deepest {
|
||||||
deepest = idx
|
deepest = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return deepest
|
return deepest
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitPathSegments returns the slash-separated segments of p with
|
|
||||||
// empty elements removed. Tolerates leading/trailing slashes and
|
|
||||||
// mixed separators on Windows (via filepath.ToSlash).
|
|
||||||
func splitPathSegments(p string) []string {
|
|
||||||
clean := strings.Trim(filepath.ToSlash(p), "/")
|
|
||||||
if clean == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Split(clean, "/")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,18 @@
|
||||||
package zddc
|
package zddc
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsAutoOwnPath(t *testing.T) {
|
func TestIsAutoOwnParent(t *testing.T) {
|
||||||
root := "/srv/zddc"
|
yes := []string{"Incoming", "Working", "Staging"}
|
||||||
cases := map[string]bool{
|
no := []string{"Issued", "Received", "incoming", "Random", "", "Working/sub"}
|
||||||
// Project-root canonical positions.
|
for _, n := range yes {
|
||||||
"/srv/zddc/Project/working": true,
|
if !IsAutoOwnParent(n) {
|
||||||
"/srv/zddc/Project/staging": true,
|
t.Errorf("IsAutoOwnParent(%q) = false, want true", n)
|
||||||
"/srv/zddc/Project/Working": true, // case-fold
|
|
||||||
"/srv/zddc/Project/STAGING": true, // case-fold
|
|
||||||
"/srv/zddc/Project/archive": false,
|
|
||||||
"/srv/zddc/Project/reviewing": false,
|
|
||||||
"/srv/zddc/Project/random": false,
|
|
||||||
|
|
||||||
// Per-party position.
|
|
||||||
"/srv/zddc/Project/archive/ACME/incoming": true,
|
|
||||||
"/srv/zddc/Project/archive/ACME/Incoming": true, // case-fold
|
|
||||||
"/srv/zddc/Project/Archive/ACME/incoming": true, // case-fold archive
|
|
||||||
"/srv/zddc/Project/archive/ACME/received": false,
|
|
||||||
"/srv/zddc/Project/archive/ACME/issued": false,
|
|
||||||
"/srv/zddc/Project/archive/ACME/mdl": false,
|
|
||||||
|
|
||||||
// Wrong depth — incoming inside something other than archive/<party>/.
|
|
||||||
"/srv/zddc/Project/working/incoming": false,
|
|
||||||
"/srv/zddc/Project/random/sub/incoming": false,
|
|
||||||
"/srv/zddc/Project/incoming": false, // depth 1 with incoming
|
|
||||||
"/srv/zddc/Project/archive/incoming": false, // depth 2
|
|
||||||
"/srv/zddc/Project/archive/ACME/incoming/sub": false, // child of incoming, not incoming itself
|
|
||||||
|
|
||||||
// Outside root.
|
|
||||||
"/elsewhere/working": false,
|
|
||||||
// Root itself or one above.
|
|
||||||
"/srv/zddc": false,
|
|
||||||
"/srv/zddc/Project": false,
|
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
}
|
||||||
if got := IsAutoOwnPath(in, root); got != want {
|
for _, n := range no {
|
||||||
t.Errorf("IsAutoOwnPath(%q, %q) = %v, want %v", in, root, got, want)
|
if IsAutoOwnParent(n) {
|
||||||
|
t.Errorf("IsAutoOwnParent(%q) = true, want false", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,29 +21,15 @@ func TestIsWormPath(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
"": false,
|
"": false,
|
||||||
"/": false,
|
"/": false,
|
||||||
"/Project/archive/ACME/issued": true,
|
"/Project/Issued": true,
|
||||||
"/Project/archive/ACME/issued/": true,
|
"/Project/Issued/": true,
|
||||||
"/Project/archive/ACME/issued/foo.pdf": true,
|
"/Project/Issued/file.pdf": true,
|
||||||
"/Project/archive/ACME/received/x": true,
|
"/Project/Issued/sub/file.pdf": true,
|
||||||
"/Project/archive/ACME/Issued/x": true, // case-fold
|
"/Project/Vendor/Issued/x.pdf": true,
|
||||||
"/Project/Archive/ACME/issued/x": true, // case-fold
|
"/Project/Vendor/Received/y": true,
|
||||||
"/Project/archive/ACME/ISSUED/x": true, // case-fold
|
"/Project/Working/draft.md": false,
|
||||||
|
"/Project/Working/Issued.txt": false, // file named Issued.txt — not a path segment
|
||||||
// Per-party MDL/incoming aren't WORM.
|
"/Project/issued/lower.pdf": false, // lowercase ≠ Issued
|
||||||
"/Project/archive/ACME/incoming/x": false,
|
|
||||||
"/Project/archive/ACME/mdl/x": false,
|
|
||||||
|
|
||||||
// Bare "issued" or "received" not under archive/<party>/ — no WORM.
|
|
||||||
"/Project/issued/x": false,
|
|
||||||
"/Project/received/x": false,
|
|
||||||
"/Project/working/issued.md": false, // file basename, not a path segment match
|
|
||||||
"/Project/working/issued": false, // "working" is not "archive"
|
|
||||||
|
|
||||||
// Self-folder is symmetric (any party name works).
|
|
||||||
"/Project/archive/Self-Org/issued/x.pdf": true,
|
|
||||||
|
|
||||||
// Nested or deep.
|
|
||||||
"/multi/Project/archive/Vendor/received/sub/file.pdf": true,
|
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := IsWormPath(in); got != want {
|
if got := IsWormPath(in); got != want {
|
||||||
|
|
@ -81,36 +38,6 @@ func TestIsWormPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWormFolderLevelIndex(t *testing.T) {
|
|
||||||
// Path /Project/archive/ACME/issued/foo.pdf
|
|
||||||
// parts: [Project, archive, ACME, issued, foo.pdf]
|
|
||||||
// issued is segment index 3, chain index 4.
|
|
||||||
if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 6); got != 4 {
|
|
||||||
t.Errorf("issued at depth 4: got %d, want 4", got)
|
|
||||||
}
|
|
||||||
// Same path, but the chain only has 4 levels (numLevels=4 → idx must be < 4).
|
|
||||||
if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 4); got != -1 {
|
|
||||||
t.Errorf("clamp: got %d, want -1", got)
|
|
||||||
}
|
|
||||||
// No WORM segment.
|
|
||||||
if got := WormFolderLevelIndex("/Project/working/foo.md", 5); got != -1 {
|
|
||||||
t.Errorf("no worm: got %d, want -1", got)
|
|
||||||
}
|
|
||||||
// Empty.
|
|
||||||
if got := WormFolderLevelIndex("", 5); got != -1 {
|
|
||||||
t.Errorf("empty: got %d, want -1", got)
|
|
||||||
}
|
|
||||||
// Nested archive/<party>/issued — deepest wins.
|
|
||||||
// parts: [P, archive, A, received, archive, B, issued, x]
|
|
||||||
// indices: 0 1 2 3 4 5 6 7
|
|
||||||
// outer match: i=1 (archive), parts[3]=received → segment idx 3, chain idx 4
|
|
||||||
// inner match: i=4 (archive), parts[6]=issued → segment idx 6, chain idx 7
|
|
||||||
// deepest = 7.
|
|
||||||
if got := WormFolderLevelIndex("/P/archive/A/received/archive/B/issued/x", 12); got != 7 {
|
|
||||||
t.Errorf("nested: got %d, want 7", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWormMaskStripsWDA(t *testing.T) {
|
func TestWormMaskStripsWDA(t *testing.T) {
|
||||||
rwcda, _ := ParseVerbSet("rwcda")
|
rwcda, _ := ParseVerbSet("rwcda")
|
||||||
masked := WormMask(rwcda)
|
masked := WormMask(rwcda)
|
||||||
|
|
@ -133,72 +60,17 @@ func TestWormMaskStripsWDA(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCanonicalCaseFold(t *testing.T) {
|
func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
|
||||||
dir := t.TempDir()
|
want := map[string]bool{
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "Working"), 0o755); err != nil {
|
"Incoming": false, "Working": false, "Staging": false,
|
||||||
t.Fatal(err)
|
"Issued": false, "Received": false,
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "ARCHIVE"), 0o755); err != nil {
|
for _, n := range SpecialFolderNames {
|
||||||
t.Fatal(err)
|
want[n] = true
|
||||||
}
|
}
|
||||||
// A regular file with a canonical name must NOT be returned (we only resolve directories).
|
for n, present := range want {
|
||||||
if err := os.WriteFile(filepath.Join(dir, "staging"), []byte{}, 0o644); err != nil {
|
if !present {
|
||||||
t.Fatal(err)
|
t.Errorf("SpecialFolderNames missing %q", n)
|
||||||
}
|
|
||||||
|
|
||||||
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 TestCanonicalLists(t *testing.T) {
|
|
||||||
hasAll := func(have, want []string) bool {
|
|
||||||
set := map[string]bool{}
|
|
||||||
for _, n := range have {
|
|
||||||
set[n] = true
|
|
||||||
}
|
|
||||||
for _, n := range want {
|
|
||||||
if !set[n] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if !hasAll(ProjectRootFolders, []string{"archive", "working", "staging", "reviewing"}) {
|
|
||||||
t.Errorf("ProjectRootFolders = %v, missing entries", ProjectRootFolders)
|
|
||||||
}
|
|
||||||
if !hasAll(PartyFolders, []string{"mdl", "incoming", "received", "issued"}) {
|
|
||||||
t.Errorf("PartyFolders = %v, missing entries", PartyFolders)
|
|
||||||
}
|
|
||||||
if !hasAll(AutoOwnCanonicalNames, []string{"working", "staging", "incoming"}) {
|
|
||||||
t.Errorf("AutoOwnCanonicalNames = %v, missing entries", AutoOwnCanonicalNames)
|
|
||||||
}
|
|
||||||
if !hasAll(VirtualOnlyCanonicalNames, []string{"reviewing"}) {
|
|
||||||
t.Errorf("VirtualOnlyCanonicalNames = %v, missing entries", VirtualOnlyCanonicalNames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue