Compare commits

...

10 commits

Author SHA1 Message Date
821ed3ee19 feat(handler): mdl/ → table-app default with embedded fallback spec
Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:

1. **Dispatcher redirect.** GET (and HEAD) on
   <project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
   302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
   and deeper mdl/ paths fall through unchanged.

2. **Default-spec fallback in RecognizeTableRequest.** When a request
   matches archive/<party>/mdl.table.html and no operator-supplied
   tables: { mdl: ... } declaration covers it, the handler returns a
   recognised request anyway. Operator declarations still win — and a
   typo'd declaration pointing at a missing file yields 404 (not a
   silent fallback).

3. **Static-file fallback for the spec yaml.** GET archive/<party>/
   mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
   default bytes (default-mdl.{table,form}.yaml in the handler package)
   when no operator file exists at that path. Operator files always
   win because the dispatcher's os.Stat finds them before reaching the
   IsDefaultMdlSpec branch.

The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.

Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
  deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
  override wins, scope check, embedded yaml served, operator yaml wins,
  scope check on yaml fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:26:53 -05:00
ce108e1eb3 feat(fs): synthesise per-user virtual home in working/ listings
ListDirectory now appends a synthetic <viewer-email>/ entry when the
listed path is exactly <project>/working/ (depth 2, case-fold) and no
real directory there matches the viewer's email under any case.

The entry has IsDir=true and a new Virtual=true flag on
listing.FileInfo (omitempty in JSON so existing clients that don't
know the field continue to render it as a regular folder). A first
write to that path materialises a real folder via the existing
auto-own pipeline (EnsureCanonicalAncestors → WriteAutoOwnZddc),
after which subsequent listings drop the synthetic entry naturally.

Anonymous viewers, listings outside working/, and listings inside a
deeper working/ subdirectory all skip the synthetic entry.

Six tests cover: appears-when-missing, suppressed-when-real-exists
(case-fold), anonymous-no-entry, staging/-no-entry, deep-working-no-
entry, and pre-existing-PascalCase-Working/ still triggers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:20:25 -05:00
55abce3448 feat(fileapi): mirror staging transmittal folders into working/
When a folder is created under <project>/staging/ whose name parses as a
ZDDC transmittal folder (YYYY-MM-DD_<tracking> (<status>) - <title>) and
whose tracking number contains -TRN- or -SUB-, also create the same-
named folder under <project>/working/ as a drafting space for staff.

The mirror is one-way and one-shot: created at staging-mkdir time only.
Renames and deletions of either side are not propagated. The
transmittal client orchestrates cleanup at issue time (move files to
archive/<recipient>/issued/, then delete both staging and working
siblings) — the server stays out of that decision.

-MDL- tracking deliberately skips the mirror; MDL deliverables live in
archive/<party>/mdl/ rows, not via the working↔staging pairing.

Implementation: mirrorStagingToWorking() in fileapi.go, called after a
successful serveFileMkdir. EnsureCanonicalAncestors handles working/'s
own auto-own .zddc; the mirror folder gets its own creator-grant on top.

Six new tests cover -TRN-/-SUB- mirror, -MDL- skip, non-transmittal
name skip, deep-path skip, and idempotency over a pre-existing sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:18:08 -05:00
a79cfd2f88 feat(zddc): EnsureCanonicalAncestors lazy-creates canonical folders on write
New helper pair:
  - ResolveCanonicalPath(fsRoot, target)              — case-fold path resolution, no side effects
  - EnsureCanonicalAncestors(fsRoot, target, email…)  — case-fold + MkdirAll + auto-own .zddc seeding

For each canonical position along the requested path the helpers
substitute on-disk casing (so /Project/working/foo lands in an existing
Working/ rather than a new sibling) and materialise missing
working/staging/archive/<party>/{mdl,incoming,received,issued}/ folders.
working/, staging/, and archive/<party>/incoming/ get a creator-owned
.zddc seeded automatically; received/, issued/, and mdl/ are created
without auto-own (WORM and data-store concerns respectively).
reviewing/ is rejected — purely virtual, never on disk.

Wired into the file API:
  - serveFilePut          — resolve before auth, ensure after auth
  - serveFileMkdir        — resolve before auth, ensure after auth, with
                            two auto-own checks (target-is-canonical OR
                            parent-is-canonical)
  - serveFileMove (POST)  — resolve src+dst, ensure dst before rename so
                            a move from working/<draft> →
                            archive/<recipient>/issued/<draft> creates
                            the per-party folders on the way in

7 new unit tests in zddc/internal/zddc/ensure_test.go cover lazy
creation, case-fold reuse, per-party incoming auto-own, WORM no-auto-own,
empty-principal skip, reviewing rejection, and traversal rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
b7e1a4310b refactor(archive): use shared zddc.ParseTransmittalFolder
The transmittal-folder grammar was duplicated as a private regex inside
the archive package. Replace the local regex with calls to the shared
parser in zddc/internal/zddc/folder.go so the grammar lives in one
place and the upcoming staging→working mirror logic can reuse it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
9f97bfab3e feat(zddc)!: per-party WORM + auto-own; case-fold tool availability
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:

  - WORM mask now triggers on archive/<party>/received/ and
    archive/<party>/issued/ (any case, any party)
  - Auto-own .zddc writes on first mkdir under working/, staging/,
    or archive/<party>/incoming/ (any case)

Predicate API:
  - IsAutoOwnPath(parentDir, fsRoot)  — replaces IsAutoOwnParent(name)
  - IsWormPath(requestPath)           — same name, new pattern
  - WormFolderLevelIndex unchanged signature, new pattern

Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).

Tool availability (apps/availability.go) is case-fold throughout:
  - mdedit:     descendants of working/
  - transmittal: descendants of staging/
  - classifier: descendants of working/, staging/, or
                archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.

Test fixtures rewritten:
  - special_test.go: covers IsAutoOwnPath / IsWormPath /
    WormFolderLevelIndex / ResolveCanonical / canonical lists
  - availability_test.go: per-party rules, case-fold scenarios
  - fileapi_test.go: rolePermissionsTestSetup now seeds
    Project-X/archive/Acme/{incoming,issued,received}/ rather than
    Vendor/{Incoming,Issued,Received}/ at the project root

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
a471de8788 refactor(zddc): extract writeAutoOwnZddc into zddc.WriteAutoOwnZddc
Pure refactor. The mkdir post-hook in handler/fileapi.go duplicated
zddc-package types; lifting the body into the package itself lets the
upcoming EnsureCanonicalAncestors helper share it without re-exposing
the file API's internals.

No behaviour change. The grant shape (creator email → rwcda + CreatedBy
audit field) and the atomic-write path through zddc.WriteFile are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
5fa5d13b10 feat(zddc): add ProjectRootFolders/PartyFolders + ResolveCanonical helper
Introduce the lowercase canonical folder model that the new auto-create
feature will key off:

  - ProjectRootFolders = [archive, working, staging, reviewing]
  - PartyFolders       = [mdl, incoming, received, issued]
  - AutoOwnCanonicalNames        = [working, staging, incoming]
  - VirtualOnlyCanonicalNames    = [reviewing]

ResolveCanonical(parentDir, logical) does a case-fold lookup against
os.ReadDir(parentDir) so a manually-created Working/ is reused rather
than shadowed by a new working/ sibling.

Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames /
WormFolderNames are kept (now Deprecated:) so dependent packages keep
compiling until the predicate rewrite lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
5363b5364c feat(zddc): add ParseTransmittalFolder + IsTrnOrSubTracking helpers
Extracts the YYYY-MM-DD_<tracking> (<status>) - <title> grammar into a
reusable parser in the zddc package, and exposes a tracking-type
predicate for -TRN- / -SUB- (case-fold). The transmittal-folder regex
was previously only inside archive/index.go where it captured just the
date; the new ParseTransmittalFolder also returns tracking, status, and
title so handlers can recognise transmittal envelopes for upcoming
staging↔working mirror logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
f450cdaf87 docs(claude): commit freely, push needs explicit ask, no squashing
The default "no commit without ask" guidance is too restrictive for this
repo's small-commit workflow. Spell out the policy: commits are made as
appropriate to the work, each push is a fresh approval, and granular
history is preferred over squash-on-push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:19:06 -05:00
22 changed files with 1945 additions and 277 deletions

View file

@ -2,6 +2,12 @@
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
This repo already has two thorough agent-facing references. **Always consult them before working** — they cover details intentionally omitted here:

View file

@ -529,6 +529,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
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
// table handler renders inline, reading rows from a directory of
// *.yaml files declared in the directory's .zddc tables: map.
@ -536,6 +553,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// so RecognizeTableRequest returns nil whenever there's no matching
// declaration and the URL falls through to the static-file path
// (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 {
handler.ServeTable(cfg, tableReq, w, r)
return
@ -596,11 +617,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
info, err := os.Stat(absPath)
if err != nil {
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
// the five canonical app HTML names AND the request directory
// is one where that app is available (Incoming/Working/Staging
// for classifier/mdedit/transmittal, anywhere for archive,
// root only for landing), resolve via the apps subsystem.
// the canonical app HTML names AND the request directory is
// one where that app is available (working/staging/incoming
// for classifier, working for mdedit, staging for
// transmittal, anywhere for archive, root only for landing),
// resolve via the apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))

View file

@ -373,6 +373,79 @@ 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
// 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

View file

@ -3,66 +3,55 @@ package apps
import (
"path/filepath"
"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
// requestDir. Rules:
// requestDir. Rules (case-insensitive on canonical folder names):
//
// - archive: every directory (multi-project, project, archive, vendor)
// - archive: every directory (multi-project, project, archive, party)
// - browse: every directory (generic file listing — also the default
// served at folder URLs without an index.html; see directory.go)
// - classifier: requestDir is, or descends from, a folder named
// "Incoming", "Working", or "Staging" (the directories where
// incoming/outgoing files get classified)
// - mdedit: requestDir is, or descends from, a "Working" folder
// (where markdown drafts are written and edited)
// - transmittal: requestDir is, or descends from, a "Staging" folder
// "working", "staging", or "incoming" (the directories where
// in-flight files get classified)
// - mdedit: requestDir is, or descends from, a "working" folder
// (where markdown drafts are written and edited, including review
// responses drafted in working/<rs-name>/)
// - transmittal: requestDir is, or descends from, a "staging" folder
// (where outgoing transmittals are prepared)
// - landing: only at the deployment root (the project picker)
//
// Operators can always drop a real <name>.html file at any path to override
// — that path is served by the static handler regardless of this function's
// result. AppAvailableAt is consulted only when no real file exists.
// Operators can always drop a real <name>.html file at any path to
// override — that path is served by the static handler regardless of
// this function's result. AppAvailableAt is consulted only when no
// 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 {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
switch app {
case "archive":
return true
case "browse":
case "archive", "browse":
return true
case "landing":
return requestDir == root
case "classifier":
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
return inAncestorWithName(root, requestDir, "working", "staging", "incoming")
case "mdedit":
return inAncestorWithName(root, requestDir, folderNamesWorking)
return inAncestorWithName(root, requestDir, "working")
case "transmittal":
return inAncestorWithName(root, requestDir, folderNamesStaging)
return inAncestorWithName(root, requestDir, "staging")
}
return false
}
// inAncestorWithName reports whether requestDir is, or has an ancestor
// (not including root itself), named one of names. The match is on the
// last segment of each directory in the chain root → requestDir.
func inAncestorWithName(root, requestDir string, names []string) bool {
// (not including root itself), whose last segment case-folds to one
// of names. Match is on segment names, case-insensitively.
func inAncestorWithName(root, requestDir string, names ...string) bool {
if requestDir == root {
return false
}
@ -72,7 +61,7 @@ func inAncestorWithName(root, requestDir string, names []string) bool {
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
for _, n := range names {
if part == n {
if strings.EqualFold(part, n) {
return true
}
}

View file

@ -14,38 +14,46 @@ func TestAppAvailableAt(t *testing.T) {
// archive: everywhere
{root, "archive", true},
{root + "/Project-A", "archive", true},
{root + "/Project-A/Working", "archive", true},
{root + "/Project-A/Outgoing", "archive", true},
{root + "/Project-A/working", "archive", true},
{root + "/Project-A/some-other-folder", "archive", true},
// landing: only at root
{root, "landing", true},
{root + "/Project-A", "landing", false},
// classifier: Incoming/Working/Staging and subtrees
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
{root, "classifier", false},
{root + "/Project-A", "classifier", false},
{root + "/Project-A/Incoming", "classifier", true},
{root + "/Project-A/Incoming/SubDir", "classifier", true},
{root + "/Project-A/Working", "classifier", true},
{root + "/Project-A/Staging", "classifier", true},
{root + "/Project-A/Outgoing", "classifier", false},
{root + "/Project-A/Working/deep/nested/path", "classifier", true},
{root + "/Project-A/working", "classifier", true},
{root + "/Project-A/working/deep/nested/path", "classifier", true},
{root + "/Project-A/staging", "classifier", true},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
{root + "/Project-A/archive/ACME/incoming/sub", "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
// mdedit: working/ only (review responses live in working/<rs-name>/)
{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/SubDir", "mdedit", true},
{root + "/Project-A/Incoming", "mdedit", false},
{root + "/Project-A/Staging", "mdedit", false},
// transmittal: Staging only
{root + "/Project-A/WORKING", "mdedit", true},
{root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/Staging/SubDir", "transmittal", true},
{root + "/Project-A/Incoming", "transmittal", false},
{root + "/Project-A/Working", "transmittal", false},
// case-sensitivity: lowercase doesn't match
{root + "/Project-A/working", "mdedit", false},
{root + "/Project-A/staging", "transmittal", false},
{root + "/Project-A/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
// unknown app
{root + "/Project-A", "weird", false},

View file

@ -9,6 +9,8 @@ import (
"strings"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// RevisionEntry holds the resolved file paths for one base revision.
@ -49,9 +51,6 @@ 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
// trackingNumber: no spaces or underscores
// revision: ~?[A-Z0-9]+(+[CBNQ][0-9]+)?
@ -101,9 +100,8 @@ func walkAndIndex(idx *Index, fsRoot, dirAbs, serverDir string) error {
}
childAbs := filepath.Join(dirAbs, name)
if m := transmittalFolderRE.FindStringSubmatch(name); m != nil {
if date, _, _, _, ok := zddc.ParseTransmittalFolder(name); ok {
// This is a transmittal folder — index its files
date := m[1]
if err := indexTransmittalFolder(idx, fsRoot, childAbs, childServerDir, date); err != nil {
// Non-fatal: log and continue
continue
@ -340,11 +338,10 @@ func (idx *Index) Rebuild(fsRoot string) (time.Duration, int, int, error) {
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
// Determine the date from the folder name
folderName := filepath.Base(transmittalDirPath)
m := transmittalFolderRE.FindStringSubmatch(folderName)
if m == nil {
date, _, _, _, ok := zddc.ParseTransmittalFolder(folderName)
if !ok {
return nil // not a transmittal folder
}
date := m[1]
// Compute server-relative path for this folder
rel, err := filepath.Rel(fsRoot, transmittalDirPath)

View file

@ -109,7 +109,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
// For transmittal folder events, schedule a debounced index update
dirPath := filepath.Dir(path)
dirName := filepath.Base(dirPath)
if transmittalFolderRE.MatchString(dirName) {
if _, _, _, _, ok := zddc.ParseTransmittalFolder(dirName); ok {
w.scheduleIndexUpdate(dirPath)
}
}

View file

@ -103,5 +103,50 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
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
}
// 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
}

View file

@ -0,0 +1,148 @@
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)
}
}

View file

@ -0,0 +1,48 @@
# 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

View file

@ -0,0 +1,41 @@
# 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 }

View file

@ -275,6 +275,15 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
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
// 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.
@ -308,6 +317,14 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
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 {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -420,6 +437,14 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
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.
srcInfo, err := os.Stat(srcAbs)
if err != nil {
@ -458,7 +483,15 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return
}
// Ensure destination's parent directory exists.
// Ensure destination's canonical ancestors are created (with auto-own
// .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 {
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -493,6 +526,11 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
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) {
return
}
@ -510,44 +548,110 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
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 {
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Auto-ownership: when the parent directory is one of the
// auto-own special folders (Incoming/Working/Staging) and the
// caller has an authenticated email, write a .zddc into the new
// folder granting the creator full control. The grant is identical
// to what the operator would write by hand — direct email pattern,
// "rwcda" verb set — so the creator can later edit the file
// normally to add collaborators.
if email := EmailFromContext(r); email != "" {
parentName := filepath.Base(filepath.Dir(abs))
if zddc.IsAutoOwnParent(parentName) {
if err := writeAutoOwnZddc(abs, email); err != nil {
// Auto-ownership for the newly-created directory itself.
//
// Two cases yield an auto-own .zddc inside abs:
// - The new directory is itself a canonical auto-own position
// (e.g. an explicit MKCOL of /Project/working). In this case
// IsAutoOwnPath(abs, cfg.Root) is true.
// - The new directory's parent is canonical auto-own — every child
// mkdir under working/, staging/, or archive/<party>/incoming/
// gets the creator's grant.
if email != "" {
if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
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.WriteHeader(http.StatusCreated)
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
}
// writeAutoOwnZddc serializes a creator-grant .zddc into newDir.
// Marshals via the same yaml encoder ParseFile reads (round-trip
// guaranteed) and writes atomically via zddc.WriteAtomic.
func writeAutoOwnZddc(newDir, email string) error {
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{
Permissions: map[string]string{email: "rwcda"},
},
CreatedBy: email,
// mirrorStagingToWorking creates a paired drafting folder under working/
// when newAbs is a transmittal-named folder under <project>/staging/. Best
// effort — failures are logged but do not affect the staging mkdir result.
//
// Eligibility:
// - newAbs's parent is exactly <project>/staging/ (case-fold)
// - filepath.Base(newAbs) parses as a transmittal folder
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
// - tracking contains -SUB- or -TRN- (case-fold)
//
// 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.

View file

@ -7,6 +7,7 @@ import (
"encoding/hex"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@ -378,19 +379,22 @@ func TestFileAPI_AnonymousDenied(t *testing.T) {
}
}
// rolePermissionsTestSetup creates a vendor-exchange shape:
// rolePermissionsTestSetup creates a project + per-party exchange shape:
//
// root .zddc: _company:r, _doc_controller:rwcda
// Vendor/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
// root .zddc: _company:r, _doc_controller:rwcda
// <project>/archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
// 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.
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()
root = t.TempDir()
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
// defined here so the vendor subtree's permissions can reference
// defined here so the per-party subtree's permissions can reference
// them by name.
rootZ := []byte(`roles:
_company:
@ -408,25 +412,21 @@ acl:
t.Fatalf("root .zddc: %v", err)
}
// Vendor subtree: narrow scope.
vendorDir := filepath.Join(root, "Vendor")
if err := os.MkdirAll(filepath.Join(vendorDir, "Incoming"), 0o755); err != nil {
t.Fatalf("mkdir Vendor/Incoming: %v", err)
// Project + per-party canonical layout.
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
for _, sub := range []string{"incoming", "issued", "received"} {
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
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)
}
if err := os.MkdirAll(filepath.Join(vendorDir, "Received"), 0o755); err != nil {
t.Fatalf("mkdir Vendor/Received: %v", err)
}
vendorZ := []byte(`acl:
partyZ := []byte(`acl:
permissions:
vendor_acme: rwcd
_doc_controller: rwcda
_company: ""
`)
if err := os.WriteFile(filepath.Join(vendorDir, ".zddc"), vendorZ, 0o644); err != nil {
t.Fatalf("vendor .zddc: %v", err)
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
t.Fatalf("party .zddc: %v", err)
}
zddc.InvalidateCache(root)
@ -462,84 +462,84 @@ acl:
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
_, do, _ := rolePermissionsTestSetup(t)
// Vendor PUTs into their Incoming → 201.
rec := do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
// Vendor PUTs into their incoming → 201.
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
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).
rec = do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
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) {
_, do, root := rolePermissionsTestSetup(t)
// Seed an existing Issued file.
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
// Seed an existing issued file.
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Vendor cannot overwrite — ancestor grant masked to r in Issued.
rec := do(http.MethodPut, "/Vendor/Issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
// 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)
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.
rec = do(http.MethodDelete, "/Vendor/Issued/spec.pdf", "rep@acme.com", nil, nil)
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
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
// 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.
rec = do(http.MethodPut, "/Vendor/Issued/new.pdf", "rep@acme.com", []byte("x"), nil)
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
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) {
_, do, root := rolePermissionsTestSetup(t)
// Without a .zddc at Vendor/Issued/ explicitly granting cr, the dc's
// inherited rwcda is masked to r. They cannot create.
rec := do(http.MethodPut, "/Vendor/Issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
// the dc's 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)
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 Vendor/Issued/.zddc. Now dc
// has cr at-or-below the WORM folder, which survives the mask.
// Operator places an explicit grant at archive/Acme/issued/.zddc.
// Now dc has cr at-or-below the WORM folder, which survives the mask.
issuedZ := []byte(`acl:
permissions:
_doc_controller: cr
`)
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("write Issued .zddc: %v", err)
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("write issued .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
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, "Vendor/Issued/2026-Q2-spec.pdf"))
got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
if string(got) != "CONTROLLED" {
t.Fatalf("body: %q", got)
}
// dc still cannot overwrite — explicit grant is cr, no w.
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
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.
rec = do(http.MethodDelete, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
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)
// Seed an Issued file and have root@ delete it (escape hatch).
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
// 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 {
t.Fatalf("seed: %v", err)
}
rec := do(http.MethodDelete, "/Vendor/Issued/mistake.pdf", "root@example.com", nil, nil)
rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
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) {
_, 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.
rec := do(http.MethodPost, "/Vendor/Incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
autoZ := filepath.Join(root, "Vendor/Incoming/2026-05-15-issue/.zddc")
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
data, err := os.ReadFile(autoZ)
if err != nil {
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
// otherwise wouldn't have ACL admin rights.
zddc.InvalidateCache(root)
rec = do(http.MethodPut, "/Vendor/Incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
if rec.Code != http.StatusCreated {
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) {
_, 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")
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("seed Issued .zddc: %v", err)
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("seed issued .zddc: %v", err)
}
zddc.InvalidateCache(root)
// Doc controller mkdir under Issued — should succeed (cr survives mask)
// but should NOT auto-write an ownership .zddc (Issued is excluded
// Doc controller mkdir under issued — should succeed (cr survives mask)
// but should NOT auto-write an ownership .zddc (issued is excluded
// from auto-own).
rec := do(http.MethodPost, "/Vendor/Issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
autoZ := filepath.Join(root, "Vendor/Issued/2026-Q2/.zddc")
autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
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,10 +657,153 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
return rec
}
// Vendor's leaf rwcd grant in Vendor/.zddc is overridden by the
// root deny under strict mode.
rec := doStrict(http.MethodPut, "/Vendor/Incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
// the root deny under strict mode.
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
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())
}
}
// --- 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)
}
}

View file

@ -41,6 +41,66 @@ import (
//go:embed tables.html
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.
type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables).
@ -86,28 +146,65 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
}
zddcPath := filepath.Join(dirAbs, ".zddc")
zf, err := zddc.ParseFile(zddcPath)
if err != nil {
if err != nil && !isNotExistError(err) {
// Malformed .zddc — log and pass through; static handler will 500
// if it cares. Recognition just says "not a declared table here."
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
return nil
}
specRel, ok := zf.Tables[name]
if !ok {
return nil
if specRel, ok := zf.Tables[name]; ok {
// Operator explicitly declared this table — honour it strictly.
// If the declared spec file is missing, return nil so the URL
// 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))
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
}
}
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
// 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,
}
}
if !fileExists(specAbs) {
return nil
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
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
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

View file

@ -227,3 +227,144 @@ tables:
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)
}
}
}

View file

@ -2,8 +2,9 @@ package listing
import "time"
// FileInfo matches Caddy's browse JSON output exactly.
// The archive browser (source.js) expects this exact shape.
// FileInfo matches Caddy's browse JSON output exactly (with one ZDDC-
// specific extension: Virtual). The archive browser (source.js) expects
// this exact shape.
type FileInfo struct {
Name string `json:"name"` // filename; directories have a trailing "/"
Size int64 `json:"size"`
@ -12,4 +13,12 @@ type FileInfo struct {
Mode uint32 `json:"mode"`
IsDir bool `json:"is_dir"`
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"`
}

View file

@ -0,0 +1,246 @@
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
}

View file

@ -0,0 +1,167 @@
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")
}
}

View file

@ -0,0 +1,48 @@
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-")
}

View file

@ -0,0 +1,89 @@
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)
}
}
}

View file

@ -1,74 +1,160 @@
package zddc
import (
"os"
"path/filepath"
"strings"
)
// SpecialFolderNames is the canonical list of folder names that drive
// per-tool availability rules and post-cascade access-decision behaviors.
// Centralized here so apps/availability and the access-control evaluator
// share one source of truth.
// ProjectRootFolders are the canonical lowercase folder names that may
// appear directly under a project root. The server resolves them
// case-insensitively on disk: a manually-created Working/ is reused
// rather than shadowed by a new working/.
//
// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator
// becomes the new subtree's admin).
// - "Working" — internal pre-publication workspace; mkdir auto-ownership.
// - "Staging" — outbound transmittal staging; mkdir auto-ownership.
// - "Issued" — immutable archive of documents we sent out. WORM mask
// strips w/d/a from non-admin principals.
// - "Received" — immutable archive of documents we accepted. Same WORM
// semantics as Issued.
// - "archive" — formal record of issued/received transmittals,
// organised by counterparty (and ourselves)
// - "working" — user-owned drafting workspace
// - "staging" — outbound-transmittal preparation
// - "reviewing" — purely virtual cross-reference of in-progress
// review responses (never written to disk)
var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}
// 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).
//
// Names are case-sensitive and exactly capitalized — operators name their
// folders this way by convention. A folder spelled differently (e.g.
// "incoming") is just a regular folder with no special semantics.
var SpecialFolderNames = []string{
"Incoming",
"Working",
"Staging",
"Issued",
"Received",
// - "mdl" — yaml-per-deliverable metadata, edited via the
// table-editor app at <party>/mdl.table.html
// - "incoming" — that party's drop point (we QC then promote)
// - "received" — immutable record of incoming we've accepted (WORM)
// - "issued" — immutable record of what we sent (WORM)
var PartyFolders = []string{"mdl", "incoming", "received", "issued"}
// AutoOwnCanonicalNames is the subset of canonical folder names where
// the file API's first-write hook auto-writes a creator-owned .zddc
// granting the creator rwcda. Excluded by design:
//
// - "archive": container only
// - "reviewing": purely virtual, never on disk
// - "mdl": yaml data store; ACL flows from archive/<party>/.zddc
// - "received" / "issued": WORM — auto-own would defeat the mask
var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}
// VirtualOnlyCanonicalNames is the subset of canonical folder names
// that are never materialised on disk by the auto-create hooks. The
// server treats requests under these prefixes as virtual routes.
//
// "reviewing" stays in ProjectRootFolders so case-fold recognition and
// future tool registration work, but EnsureCanonicalAncestors skips
// MkdirAll for it.
var VirtualOnlyCanonicalNames = []string{"reviewing"}
// 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)
}
// AutoOwnFolderNames is the subset of SpecialFolderNames where the file
// API's mkdir post-hook auto-writes a creator-owned .zddc into the new
// subdirectory. Issued / Received are deliberately excluded — filing in
// the immutable archive should not create owned subtrees inside it.
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
// WormFolderNames is the subset of SpecialFolderNames covered by the
// post-cascade WORM mask. Any path whose chain crosses one of these
// names has w/d/a stripped from non-admin principals.
var WormFolderNames = []string{"Issued", "Received"}
// IsAutoOwnParent reports whether a folder named name should trigger
// the mkdir auto-ownership .zddc write when a child is created inside
// it. Used by the file API's mkdir handler.
func IsAutoOwnParent(name string) bool {
for _, n := range AutoOwnFolderNames {
if name == n {
return true
// ResolveCanonical returns the on-disk name of the canonical folder
// 'logical' (lowercase) inside parentDir, or "" if no case variant
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
// when "" is returned.
//
// 'logical' is matched case-insensitively against entries returned by
// os.ReadDir(parentDir). The first matching directory entry wins (if
// an operator created both Working/ and working/ on a case-sensitive
// filesystem, the order is filesystem-dependent — that's an unsupported
// state we don't try to recover from).
//
// Returns "" with no error if parentDir doesn't exist or has no match.
func ResolveCanonical(parentDir, logical string) (string, error) {
entries, err := os.ReadDir(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if strings.EqualFold(e.Name(), logical) {
return e.Name(), nil
}
}
return "", nil
}
// IsAutoOwnPath reports whether parentDir is one of the canonical
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
// inside such a directory should receive a creator-owned .zddc.
//
// 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 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.
// IsWormPath reports whether requestPath crosses an
// archive/<party>/received/ or archive/<party>/issued/ segment chain.
// Pure path-segment check; case-fold on canonical names.
//
// The party segment is unrestricted — any directory under archive/ is
// treated as a party, including the self-folder. requestPath may be a
// URL path ("/Project/archive/ACME/issued/foo.pdf") 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
}
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 false
@ -82,41 +168,52 @@ func IsWormPath(requestPath string) bool {
// are the deliberate escape hatch for mis-filed documents.
//
// The WORM mask is split-aware via WormFolderLevelIndex: grants
// inherited from ancestors above the Issued/Received folder are
// inherited from ancestors above the received/issued folder are
// 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
// Issued folder explicitly granting `_doc_controller: cr`.
// received/issued folder explicitly granting `_doc_controller: cr`.
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
// WormFolderLevelIndex returns the chain index of the deepest
// "Issued" or "Received" segment in requestPath. The chain
// archive/<party>/(received|issued) segment in requestPath. The chain
// corresponds to the directory tree from root (index 0) to the
// 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 actual range (e.g. a request to a file inside an Issued
// folder has a chain that only covers up to the Issued directory,
// not the file itself).
// numLevels is len(chain.Levels); used to clamp results to the chain's
// actual range. URL segment i lives at chain index i+1 (root is chain
// index 0), so the WORM segment at parts[i+2] corresponds to chain
// index i+3.
//
// Returns -1 if no WORM segment is in the request path or the
// computed index is out of range. The returned index satisfies
// Returns -1 if no WORM segment is in the request path or the computed
// index is out of range. The returned index satisfies
// 0 <= index < numLevels.
func WormFolderLevelIndex(requestPath string, numLevels int) int {
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
if clean == "" || numLevels <= 0 {
if numLevels <= 0 {
return -1
}
parts := splitPathSegments(requestPath)
deepest := -1
for i, seg := range strings.Split(clean, "/") {
for _, name := range WormFolderNames {
if seg == name {
// URL segment i lives at chain index i+1 (root is index 0).
idx := i + 1
if idx < numLevels && idx > deepest {
deepest = idx
}
for i := 0; i+2 < len(parts); i++ {
if !strings.EqualFold(parts[i], "archive") {
continue
}
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
idx := i + 3
if idx < numLevels && idx > deepest {
deepest = idx
}
}
}
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, "/")
}

View file

@ -1,35 +1,78 @@
package zddc
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestIsAutoOwnParent(t *testing.T) {
yes := []string{"Incoming", "Working", "Staging"}
no := []string{"Issued", "Received", "incoming", "Random", "", "Working/sub"}
for _, n := range yes {
if !IsAutoOwnParent(n) {
t.Errorf("IsAutoOwnParent(%q) = false, want true", n)
}
func TestIsAutoOwnPath(t *testing.T) {
root := "/srv/zddc"
cases := map[string]bool{
// Project-root canonical positions.
"/srv/zddc/Project/working": true,
"/srv/zddc/Project/staging": true,
"/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 _, n := range no {
if IsAutoOwnParent(n) {
t.Errorf("IsAutoOwnParent(%q) = true, want false", n)
for in, want := range cases {
if got := IsAutoOwnPath(in, root); got != want {
t.Errorf("IsAutoOwnPath(%q, %q) = %v, want %v", in, root, got, want)
}
}
}
func TestIsWormPath(t *testing.T) {
cases := map[string]bool{
"": false,
"/": false,
"/Project/Issued": true,
"/Project/Issued/": true,
"/Project/Issued/file.pdf": true,
"/Project/Issued/sub/file.pdf": true,
"/Project/Vendor/Issued/x.pdf": true,
"/Project/Vendor/Received/y": true,
"/Project/Working/draft.md": false,
"/Project/Working/Issued.txt": false, // file named Issued.txt — not a path segment
"/Project/issued/lower.pdf": false, // lowercase ≠ Issued
"": false,
"/": false,
"/Project/archive/ACME/issued": true,
"/Project/archive/ACME/issued/": true,
"/Project/archive/ACME/issued/foo.pdf": true,
"/Project/archive/ACME/received/x": true,
"/Project/archive/ACME/Issued/x": true, // case-fold
"/Project/Archive/ACME/issued/x": true, // case-fold
"/Project/archive/ACME/ISSUED/x": true, // case-fold
// Per-party MDL/incoming aren't WORM.
"/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 {
if got := IsWormPath(in); got != want {
@ -38,6 +81,36 @@ 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) {
rwcda, _ := ParseVerbSet("rwcda")
masked := WormMask(rwcda)
@ -60,17 +133,72 @@ func TestWormMaskStripsWDA(t *testing.T) {
}
}
func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
want := map[string]bool{
"Incoming": false, "Working": false, "Staging": false,
"Issued": false, "Received": false,
func TestResolveCanonicalCaseFold(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Working"), 0o755); err != nil {
t.Fatal(err)
}
for _, n := range SpecialFolderNames {
want[n] = true
if err := os.MkdirAll(filepath.Join(dir, "ARCHIVE"), 0o755); err != nil {
t.Fatal(err)
}
for n, present := range want {
if !present {
t.Errorf("SpecialFolderNames missing %q", n)
// A regular file with a canonical name must NOT be returned (we only resolve directories).
if err := os.WriteFile(filepath.Join(dir, "staging"), []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cases := map[string]string{
"working": "Working", // PascalCase wins because it exists on disk
"WORKING": "Working",
"Working": "Working",
"archive": "ARCHIVE",
"reviewing": "", // not present
"staging": "", // present as a file, not a directory — must skip
}
for logical, want := range cases {
got, err := ResolveCanonical(dir, logical)
if err != nil {
t.Errorf("ResolveCanonical(%q): %v", logical, err)
continue
}
if got != want {
t.Errorf("ResolveCanonical(%q) = %q, want %q", logical, got, want)
}
}
}
func TestResolveCanonicalMissingParent(t *testing.T) {
got, err := ResolveCanonical(filepath.Join(t.TempDir(), "does-not-exist"), "working")
if err != nil {
t.Errorf("expected nil error for missing parent, got %v", err)
}
if got != "" {
t.Errorf("expected empty result for missing parent, got %q", got)
}
}
func 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)
}
}