fix(history): serve .history snapshots as ACL-gated content (carve dot-prefix guard)

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/<stem>/<snap>.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-02 10:16:12 -05:00
parent 90a0901951
commit b9ebee7551
3 changed files with 72 additions and 9 deletions

View file

@ -866,6 +866,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if seg == handler.ZddcFileBasename && i == len(segments)-1 { if seg == handler.ZddcFileBasename && i == len(segments)-1 {
continue 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 { if hiddenOK {
continue continue
} }

View file

@ -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)
}
}

View file

@ -60,11 +60,14 @@ const (
auditFieldPreviousSha = "previous_sha" auditFieldPreviousSha = "previous_sha"
) )
// historyDirName is the dot-prefixed bookkeeping folder under each // HistoryDirName is the dot-prefixed history folder under each
// record-containing directory. resolveTargetPath's dot-segment // history-tracked directory. WRITES are server-only — the file API's
// rejection means no client URL can reach into .history/ — only the // segment check rejects client PUT/DELETE/POST into it. READS (GET/HEAD)
// server's own history-write code path touches it. // are carved out of the dispatcher's dot-prefix guard so snapshots are
const historyDirName = ".history" // 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 // WriteRecordResult carries what serveFilePut needs to surface a
// response after a successful record write. // 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 // (timestamp+sha8 of priorBody) — rewriting it idempotently
// is harmless when the live write later succeeds. // is harmless when the live write later succeeds.
if priorExisted { if priorExisted {
histDir := filepath.Join(dir, historyDirName, stripExt(base)) histDir := filepath.Join(dir, HistoryDirName, stripExt(base))
if err := os.MkdirAll(histDir, 0o755); err != nil { if err := os.MkdirAll(histDir, 0o755); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err) return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
} }
@ -610,7 +613,7 @@ type HistoryEntry struct {
func ListHistory(abs string) ([]HistoryEntry, error) { func ListHistory(abs string) ([]HistoryEntry, error) {
dir := filepath.Dir(abs) dir := filepath.Dir(abs)
base := filepath.Base(abs) base := filepath.Base(abs)
histDir := filepath.Join(dir, historyDirName, stripExt(base)) histDir := filepath.Join(dir, HistoryDirName, stripExt(base))
ents, err := os.ReadDir(histDir) ents, err := os.ReadDir(histDir)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@ -634,7 +637,7 @@ func ListHistory(abs string) ([]HistoryEntry, error) {
} }
ts := stem[:idx] ts := stem[:idx]
sha := stem[idx+1:] 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. // Pull author + revision from the archived body.
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil { if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
snap := parsePriorAudit(data) snap := parsePriorAudit(data)
@ -844,7 +847,7 @@ func IsTextHistoryCandidate(abs string) bool {
} }
func mdHistoryDir(abs string) string { 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 // mdStamp renders t as the colon-free snapshot timestamp with the trailing