From b9ebee75518deb40bbb90684e3434c789f32b3b4 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 2 Jun 2026 10:16:12 -0500 Subject: [PATCH] fix(history): serve .history snapshots as ACL-gated content (carve dot-prefix guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a history snapshot in the tree 404'd: the dispatcher's dot-prefix guard blocks every .-segment URL, and the preview fetch hit the raw .history//.md path. But .history is ACL-modeled content (it inherits the shadowed file's .zddc chain), not infra like .devshell — so the guard was redundant with permissions there. Carve GET/HEAD of .history out of the dot-prefix guard: snapshots are now fetchable as ordinary ACL-gated files (read the live file → read its history). Writes into .history stay blocked, and the listing dot-filter still hides it from default views unless ?hidden is set. Export handler.HistoryDirName for the dispatcher. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main.go | 10 +++++++ zddc/cmd/zddc-server/main_test.go | 50 +++++++++++++++++++++++++++++++ zddc/internal/handler/history.go | 21 +++++++------ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index d3fcbd7..0eb7eae 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -866,6 +866,16 @@ 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 6e294ff..3ddc33a 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -1065,3 +1065,53 @@ 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) + } +} diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index ed0cc6a..ae24c9d 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -60,11 +60,14 @@ const ( auditFieldPreviousSha = "previous_sha" ) -// historyDirName is the dot-prefixed bookkeeping folder under each -// record-containing directory. resolveTargetPath's dot-segment -// rejection means no client URL can reach into .history/ — only the -// server's own history-write code path touches it. -const historyDirName = ".history" +// 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" // WriteRecordResult carries what serveFilePut needs to surface a // response after a successful record write. @@ -262,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, HistoryDirName, stripExt(base)) if err := os.MkdirAll(histDir, 0o755); err != nil { return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err) } @@ -610,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, HistoryDirName, stripExt(base)) ents, err := os.ReadDir(histDir) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -634,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(HistoryDirName, stripExt(base), name)} // Pull author + revision from the archived body. if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil { snap := parsePriorAudit(data) @@ -844,7 +847,7 @@ func IsTextHistoryCandidate(abs string) bool { } func mdHistoryDir(abs string) string { - return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs))) + return filepath.Join(filepath.Dir(abs), HistoryDirName, stripExt(filepath.Base(abs))) } // mdStamp renders t as the colon-free snapshot timestamp with the trailing