refactor(history): store under .zddc.d/history/; drop .history carve-out + dead .devshell
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 <dir>/.zddc.d/history/<stem>/
(was <dir>/.history/<stem>/). Const renamed .history → .zddc.d/history and
unexported (the only external user was the dispatch carve-out). The history
VIEWER endpoints (<record>.yaml?history=1, <file>?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) <noreply@anthropic.com>
This commit is contained in:
parent
1eeaa1bd96
commit
e4e0fedaa2
12 changed files with 62 additions and 115 deletions
|
|
@ -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<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
|
||||
| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
||||
| Dev (Dockerfile) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
||||
| Local dev iteration | n/a | Use `tool/dist/<tool>.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** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
||||
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
||||
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
||||
- **Per-row history** — `<dir>/.history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
||||
- **Per-row history** — `<dir>/.zddc.d/history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
||||
|
||||
**Default-MDL fallback at `archive/<party>/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/<party>/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 `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` 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 `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.zddc.d/history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` 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: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.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/<tracking>.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 (`<tracking>_<rev>+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 `<dir>/<app>.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.
|
||||
|
|
|
|||
|
|
@ -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 `<name>.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 `<dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>`, 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 `<dir>/.zddc.d/history/<base>/<RFC3339Nano>-<sha8>.<ext>`, 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (`/`,
|
||||
`/<project>/`, `/.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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 <newstem>: %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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//
|
||||
// 2. Prior bytes are preserved. Before the live file is overwritten
|
||||
// the previous content is copied (byte-for-byte) into
|
||||
// <dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. The
|
||||
// <dir>/.zddc.d/history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. 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: <dir>/.zddc.d/history/<stem>/. It sits under
|
||||
// the single reserved .zddc.d/ sidecar namespace (all zddc bookkeeping), so
|
||||
// it is reached only through the history endpoints (records:
|
||||
// <record>.yaml?history=1; text: <file>?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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue