From e4e0fedaa24089ea85751997e9fe9fd9d98e1feb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 2 Jun 2026 13:48:41 -0500 Subject: [PATCH] refactor(history): store under .zddc.d/history/; drop .history carve-out + dead .devshell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate edit-history bookkeeping under the single reserved .zddc.d/ sidecar (where tokens + access logs already live), instead of its own top-level .history/ dot-name: - history.go: record + text history now write/read /.zddc.d/history// (was /.history//). Const renamed .history → .zddc.d/history and unexported (the only external user was the dispatch carve-out). The history VIEWER endpoints (.yaml?history=1, ?history=…) read it server-side, so they keep working for anyone with read on the live file; the raw store is bookkeeping, blocked by the existing dot-prefix guard. - main.go: drop the .history GET carve-out (b9ebee7) — superseded; history is reached via the viewer, not raw browsing. Reword the guard comment to "reserve .zddc.d/ bookkeeping" (Part B will replace the blanket block with a .zddc.d/ admin-fence). - Delete dead .devshell references (the dev-shell was dropped from the chart): guard comment, paths.go comment, test fixtures/cases (→ .zddc.d), and docs. This is Part A of the approved plan: ship history in its permanent home so we never migrate it twice. Tests updated to the new paths; the obsolete TestDispatchHistoryReadCarveOut is removed (raw-block covered by TestDispatchHidesDotPrefixedSegments, viewer by mdhistory_test). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 8 +-- ARCHITECTURE.md | 2 +- zddc/README.md | 8 +-- zddc/cmd/zddc-server/main.go | 23 +++---- zddc/cmd/zddc-server/main_test.go | 80 ++++++------------------- zddc/internal/handler/fileapi_test.go | 8 +-- zddc/internal/handler/history.go | 26 ++++---- zddc/internal/handler/history_test.go | 10 ++-- zddc/internal/handler/mdhistory_test.go | 2 +- zddc/internal/handler/paths.go | 6 +- zddc/internal/listing/listing_test.go | 2 +- zddc/internal/zddc/scan_test.go | 2 +- 12 files changed, 62 insertions(+), 115 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 10c446e..26581f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,7 @@ Format: `trackingNumber_revision (status) - title.extension` | Image | Chart pin | Embeds | |---|---|---| | Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v` | Stable-labeled bytes from the tagged release commit | -| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) | +| Dev (Dockerfile) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) | | Local dev iteration | n/a | Use `tool/dist/.html` directly; binary's embedded copy lags | On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself): @@ -401,7 +401,7 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f - **Nested sub-tables** — `/sub-list/table.yaml` is its own self-contained table at `/sub-list/table.html`. Composition, not violation. - **Per-row attachments** — `/.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path. - **Drafts / staging** — `/.drafts/.yaml` (dot-prefix → hidden from listings as well as from the table). -- **Per-row history** — `/.history//-.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below. +- **Per-row history** — `/.zddc.d/history//-.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below. **Default-MDL fallback at `archive//mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive//mdl/`, presence-based discovery is the rule. @@ -433,7 +433,7 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every - `revision` — `1` on create, `+1` per update - `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence -**History layout**: for any record at `/.`, the prior version is archived at `/.history//-.` before the live file is overwritten. Per-record subfolder under `.history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it). +**History layout**: for any record at `/.`, the prior version is archived at `/.zddc.d/history//-.` before the live file is overwritten. Per-record subfolder under `.zddc.d/history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it). **Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes). @@ -746,7 +746,7 @@ local path that fails loudly and visibly on the developer's terminal. - Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. - ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model." - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". -- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. +- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by an upstream proxy to gate an admin-only sub-app on root-admin status without that app learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. - **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `/.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized). - **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f8489d..a014eff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -470,7 +470,7 @@ app.state.subscribe((property, newValue) => { **Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `.form.yaml` is the trigger; without it, the URL falls through to static-file serving. -**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `/.history//-.`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration. +**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `/.zddc.d/history//-.`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration. **Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive. diff --git a/zddc/README.md b/zddc/README.md index 16831ec..7b3b882 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -796,7 +796,7 @@ there at all in the worked example), and no other vendor's name leaks via listin Two prefixes are filtered from listings under `ZDDC_ROOT`: -- **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded +- **`.`-prefixed** (e.g. `/.zddc.d/`, `/Project-A/.internal/notes.md`) — excluded from listings **and** 404 on direct HTTP access. The recognized virtual prefixes (`.archive`, `.admin`) are explicitly permitted through. This lets operators store side-state (caches, dev-shell home dirs, snapshot staging) on the same volume @@ -1489,13 +1489,13 @@ The intended use case is gating *adjacent* services on the same pod / host that don't have their own ACL. Concretely: the dev-shell deployment runs both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses `forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach -`/devshell/*` (the IDE) before forwarding. zddc-server's own routes (`/`, +an admin-only sub-app before forwarding. zddc-server's own routes (`/`, `//`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL and don't go through this endpoint. ```caddy -# example: protect /devshell/* with forward_auth on /.auth/admin -handle_path /devshell/* { +# example: protect an admin-only sub-app with forward_auth on /.auth/admin +handle_path /admin-app/* { forward_auth 127.0.0.1:9090 { uri /.auth/admin copy_headers X-Auth-Request-Email diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index a16ed71..108a568 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -825,11 +825,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Reserve dot-prefixed path segments. The listing pipeline already hides // hidden entries (internal/fs/tree.go:90, projectshandler.go:40), - // but direct URL access would still serve them. 404 here so hidden trees - // like /srv/.devshell (the in-image dev-shell's persistent home dir on - // the same Azure Files PVC as served data) cannot be fetched. The - // recognized virtual prefixes (.profile handled above, cfg.IndexPath - // handled below) are explicitly allowed through. + // but direct URL access would still serve them. 404 here so server + // bookkeeping under the reserved .zddc.d/ sidecar (tokens, history, …) + // cannot be fetched raw. The recognized virtual prefixes (.profile + // handled above, cfg.IndexPath handled below) are explicitly allowed + // through. + // + // (Part B will replace this blanket block with a .zddc.d/ admin-fence so + // dot-content is uniformly ACL-governed; until then the block stands.) // // Also reserve the apps cache directory (`_app`): the cached HTML files // there must be served via the apps resolver (with proper headers and @@ -866,16 +869,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps if seg == handler.ZddcFileBasename && i == len(segments)-1 { continue } - // `.history/` is ACL-modeled edit-history content, not - // infrastructure: it inherits the same .zddc chain as the file it - // shadows, so reads need no guard beyond permissions (if you can - // read the live file you can read its history). Carve GET/HEAD - // through to the ACL-gated file serve; writes stay blocked (the - // file API has its own segment check). The listing dot-filter - // still keeps it out of default views unless ?hidden is set. - if seg == handler.HistoryDirName && (r.Method == http.MethodGet || r.Method == http.MethodHead) { - continue - } if hiddenOK { continue } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 3ddc33a..acb55ed 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -26,20 +26,18 @@ import ( // rejects requests whose URL contains a dot-prefixed segment (other than // the recognized virtual prefixes .archive and /.profile handled separately). // -// The guard exists so the in-image dev-shell can keep persistent state -// (settings, source clones, Go module cache) under /srv/.devshell on the -// same Azure Files PVC as served data without ever exposing those files -// via direct HTTP fetch. +// The guard keeps server bookkeeping under the reserved .zddc.d/ sidecar +// (tokens, history, …) from being fetched raw over HTTP. (Part B will +// replace this blanket block with a .zddc.d/ admin-fence.) func TestDispatchHidesDotPrefixedSegments(t *testing.T) { root := t.TempDir() - // Realistic shape: a project dir, a hidden top-level dir, and a hidden - // sibling of a normal file inside the project. + // Realistic shape: a project dir, a reserved .zddc.d/ token store, and a + // hidden sibling of a normal file inside the project. mustMkdir(t, filepath.Join(root, "Project-A")) mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok") - mustMkdir(t, filepath.Join(root, ".devshell")) - mustMkdir(t, filepath.Join(root, ".devshell", "coder")) - mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret") + mustMkdir(t, filepath.Join(root, ".zddc.d", "tokens")) + mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "secret") mustMkdir(t, filepath.Join(root, "Project-A", ".internal")) mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret") @@ -60,9 +58,9 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) { path string wantStatus int }{ - // Hidden top-level dir — every shape blocked. - {"hidden top dir", "/.devshell/", http.StatusNotFound}, - {"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound}, + // Reserved .zddc.d/ bookkeeping — every shape blocked. + {"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound}, + {"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound}, // Hidden segment under a real project dir — also blocked. {"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound}, @@ -336,11 +334,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // Reserved segment guard still applies to writes. - req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNotFound { - t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code) + t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code) } } @@ -1066,52 +1064,8 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { }) } -// TestDispatchHistoryReadCarveOut — .history/ snapshots are readable via -// GET/HEAD (ACL-gated, like the files they shadow), but writes into -// .history/ and reads of genuine infra (.devshell) stay blocked. -func TestDispatchHistoryReadCarveOut(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n permissions:\n \"*\": rwcd\n") - snapDir := filepath.Join(root, "Proj", "docs", ".history", "notes") - mustMkdir(t, snapDir) - mustWrite(t, filepath.Join(snapDir, "20260101T000000.000Z-a@x.com.md"), "OLD VERSION") - mustMkdir(t, filepath.Join(root, ".devshell")) - mustWrite(t, filepath.Join(root, ".devshell", "secret"), "TOPSECRET") - - 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) - withEmail := func(req *http.Request) *http.Request { - return req.WithContext(handler.WithEmail(req.Context(), "u@x.com")) - } - - // GET a snapshot → served (carve-out + ACL read). - req := withEmail(httptest.NewRequest(http.MethodGet, "/Proj/docs/.history/notes/20260101T000000.000Z-a@x.com.md", nil)) - rec := httptest.NewRecorder() - dispatch(cfg, idx, ring, nil, nil, rec, req) - if rec.Code != http.StatusOK || rec.Body.String() != "OLD VERSION" { - t.Fatalf(".history GET: code=%d body=%q, want 200 OLD VERSION", rec.Code, rec.Body.String()) - } - - // PUT into .history → blocked (carve-out is GET/HEAD only). - req = withEmail(httptest.NewRequest(http.MethodPut, "/Proj/docs/.history/notes/x.md", strings.NewReader("nope"))) - rec = httptest.NewRecorder() - dispatch(cfg, idx, ring, nil, nil, rec, req) - if rec.Code == http.StatusOK || rec.Code == http.StatusCreated { - t.Errorf(".history PUT should be blocked, got %d", rec.Code) - } - if _, err := os.Stat(filepath.Join(snapDir, "x.md")); err == nil { - t.Errorf(".history PUT must not create a file") - } - - // .devshell stays blocked. - rec = httptest.NewRecorder() - dispatch(cfg, idx, ring, nil, nil, rec, withEmail(httptest.NewRequest(http.MethodGet, "/.devshell/secret", nil))) - if rec.Code != http.StatusNotFound { - t.Errorf(".devshell GET: code=%d, want 404", rec.Code) - } -} +// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live +// under the reserved .zddc.d/history/ namespace — blocked raw by the +// dot-prefix guard, like any bookkeeping, and surfaced only through the +// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments; +// the viewer is covered in mdhistory_test.go.) diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index b2c9b7c..07c11af 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -759,7 +759,7 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) { "Docs/notes.md": "# notes\n", }) // Pre-seed a history snapshot for notes.md. - histOld := filepath.Join(root, "Docs", ".history", "notes") + histOld := filepath.Join(root, "Docs", ".zddc.d", "history", "notes") if err := os.MkdirAll(histOld, 0o755); err != nil { t.Fatal(err) } @@ -775,7 +775,7 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String()) } - if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil { + if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil { t.Errorf("history dir not renamed to : %v", err) } if _, err := os.Stat(histOld); !os.IsNotExist(err) { @@ -793,10 +793,10 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String()) } - if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil { + if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil { t.Errorf("history should stay behind on a cross-dir move: %v", err) } - if _, err := os.Stat(filepath.Join(root, "Other", ".history", "renamed")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, "Other", ".zddc.d", "history", "renamed")); !os.IsNotExist(err) { t.Errorf("history should NOT follow a cross-dir move; err=%v", err) } } diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index 586af37..5ff1d84 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -9,7 +9,7 @@ // // 2. Prior bytes are preserved. Before the live file is overwritten // the previous content is copied (byte-for-byte) into -// /.history//-.. The +// /.zddc.d/history//-.. The // filename embeds the timestamp + the SHA-256 prefix of the prior // bytes — the same value that's stamped into the new record's // previous_sha field — so the chain is auditable. @@ -60,14 +60,14 @@ const ( auditFieldPreviousSha = "previous_sha" ) -// HistoryDirName is the dot-prefixed history folder under each -// history-tracked directory. WRITES are server-only — the file API's -// segment check rejects client PUT/DELETE/POST into it. READS (GET/HEAD) -// are carved out of the dispatcher's dot-prefix guard so snapshots are -// fetchable as ordinary ACL-gated content (the .history subtree inherits -// the same .zddc chain as the files it shadows); the listing dot-filter -// still keeps it out of default views unless ?hidden is set. -const HistoryDirName = ".history" +// historyDirRel is where edit-history snapshots live under each +// history-tracked directory: /.zddc.d/history//. It sits under +// the single reserved .zddc.d/ sidecar namespace (all zddc bookkeeping), so +// it is reached only through the history endpoints (records: +// .yaml?history=1; text: ?history=…), which read it +// server-side — never as raw browsable content. Both the record and text +// history paths share this base. +const historyDirRel = ".zddc.d/history" // WriteRecordResult carries what serveFilePut needs to surface a // response after a successful record write. @@ -265,7 +265,7 @@ func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, prin // (timestamp+sha8 of priorBody) — rewriting it idempotently // is harmless when the live write later succeeds. if priorExisted { - histDir := filepath.Join(dir, HistoryDirName, stripExt(base)) + histDir := filepath.Join(dir, historyDirRel, stripExt(base)) if err := os.MkdirAll(histDir, 0o755); err != nil { return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err) } @@ -613,7 +613,7 @@ type HistoryEntry struct { func ListHistory(abs string) ([]HistoryEntry, error) { dir := filepath.Dir(abs) base := filepath.Base(abs) - histDir := filepath.Join(dir, HistoryDirName, stripExt(base)) + histDir := filepath.Join(dir, historyDirRel, stripExt(base)) ents, err := os.ReadDir(histDir) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -637,7 +637,7 @@ func ListHistory(abs string) ([]HistoryEntry, error) { } ts := stem[:idx] sha := stem[idx+1:] - entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(HistoryDirName, stripExt(base), name)} + entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirRel, stripExt(base), name)} // Pull author + revision from the archived body. if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil { snap := parsePriorAudit(data) @@ -860,7 +860,7 @@ func matchHistoryGlobs(globs []string, base string) bool { } func mdHistoryDir(abs string) string { - return filepath.Join(filepath.Dir(abs), HistoryDirName, stripExt(filepath.Base(abs))) + return filepath.Join(filepath.Dir(abs), historyDirRel, stripExt(filepath.Base(abs))) } // mdStamp renders t as the colon-free snapshot timestamp with the trailing diff --git a/zddc/internal/handler/history_test.go b/zddc/internal/handler/history_test.go index 673b10b..dde9313 100644 --- a/zddc/internal/handler/history_test.go +++ b/zddc/internal/handler/history_test.go @@ -103,7 +103,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) { } // No history dir yet (create only). - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf(".history/ should not exist after create-only; got err=%v", err) } @@ -149,7 +149,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { } // .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes). - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history", "ACM-PRJ-EL-SPC-0001") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001") ents, err := os.ReadDir(histDir) if err != nil { t.Fatalf("read history dir: %v", err) @@ -186,7 +186,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) { t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String()) } - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf("history dir should not exist after 412 conflict; got err=%v", err) } @@ -365,11 +365,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { } // History at archive/0330C1/.history/ssr/, NOT at archive/.history/. - wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr") + wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".zddc.d", "history", "ssr") if _, err := os.Stat(wanted); err != nil { t.Fatalf("expected history at %s; err=%v", wanted, err) } - bad := filepath.Join(cfg.Root, "Project", "archive", ".history") + bad := filepath.Join(cfg.Root, "Project", "archive", ".zddc.d", "history") if _, err := os.Stat(bad); !os.IsNotExist(err) { t.Errorf("history must NOT live at %s; err=%v", bad, err) } diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go index 5f58a34..a40f963 100644 --- a/zddc/internal/handler/mdhistory_test.go +++ b/zddc/internal/handler/mdhistory_test.go @@ -40,7 +40,7 @@ func countSnapshots(t *testing.T, histDir string) int { func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { dir := t.TempDir() abs := filepath.Join(dir, "notes.md") - histDir := filepath.Join(dir, ".history", "notes") + histDir := filepath.Join(dir, ".zddc.d", "history", "notes") // ── create: one snapshot, authored, current ── mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com")) diff --git a/zddc/internal/handler/paths.go b/zddc/internal/handler/paths.go index 8914635..4a69265 100644 --- a/zddc/internal/handler/paths.go +++ b/zddc/internal/handler/paths.go @@ -13,9 +13,9 @@ import ( // resolvePath translates a URL `path=` query (relative to fsRoot, with // '/' separator and leading '/') into an absolute filesystem path. It // rejects path traversal and any segment beginning with '.' or '_' so -// reserved namespaces (e.g. .devshell) cannot be addressed through -// admin APIs. Returns the cleaned absolute path or an error suitable -// for a 404. +// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be +// addressed through admin APIs. Returns the cleaned absolute path or an +// error suitable for a 404. func resolvePath(fsRoot, urlPath string) (string, error) { urlPath = strings.TrimSpace(urlPath) if urlPath == "" { diff --git a/zddc/internal/listing/listing_test.go b/zddc/internal/listing/listing_test.go index a5819ef..2970d4b 100644 --- a/zddc/internal/listing/listing_test.go +++ b/zddc/internal/listing/listing_test.go @@ -18,7 +18,7 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) { "Project-A", "Project-B", ".zddc", // hidden file - ".devshell", // hidden dir + ".zddc.d", // hidden dir (reserved bookkeeping) "_template", // scaffolding dir "_archive", // scaffolding dir "_notes.txt", // scaffolding file diff --git a/zddc/internal/zddc/scan_test.go b/zddc/internal/zddc/scan_test.go index d61fbfc..fc54fc1 100644 --- a/zddc/internal/zddc/scan_test.go +++ b/zddc/internal/zddc/scan_test.go @@ -25,7 +25,7 @@ func scanFixture(t *testing.T) string { mk("ProjectA/Sub/.zddc", "title: A-sub\n") mk("ProjectB/.zddc", "title: B\n") // Reserved-prefix subtrees must be pruned. - mk(".devshell/.zddc", "title: hidden\n") + mk(".zddc.d/.zddc", "title: hidden\n") mk("_template/.zddc", "title: scaffold\n") return root }