From 6efe71e573bee27139e52c512bafe537c8540f67 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 28 May 2026 12:37:35 -0500 Subject: [PATCH] feat(server): edit-history versioning for working-folder markdown A history: true .zddc subtree (enabled by default on archive//working/) routes markdown PUTs through WriteTextWithHistory: each save snapshots the content into a hidden, immutable .history// store (content-addressed blobs + an append-only log.jsonl carrying server-stamped {ts, email, sha, prev}) before writing the live file. The live file at its natural path stays the source of truth; no symlinks, no audit in the body/filename. Reads: GET ?history=1 lists versions (newest-first, current flagged); GET ?history= returns that version's bytes (hex-id guard against traversal). Listings carry a per-file History flag so the browse client knows where to offer the affordance. History is subtree-inheriting and ignores inherit:false ACL fences (versioning is a write behavior, not a permission), so fenced per-user homes under working/ are covered too. No-op saves dedup; pre-existing files lazy-seed their origin version. Records (.yaml) keep their existing in-body-audit history path. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 22 +- zddc/internal/fs/tree.go | 5 + zddc/internal/handler/fileapi.go | 10 + zddc/internal/handler/history.go | 240 ++++++++++++++++++++++ zddc/internal/handler/mdhistory_test.go | 221 ++++++++++++++++++++ zddc/internal/listing/types.go | 8 + zddc/internal/zddc/cascade.go | 20 ++ zddc/internal/zddc/defaults.zddc.yaml | 7 + zddc/internal/zddc/file.go | 18 ++ zddc/internal/zddc/history_policy_test.go | 58 ++++++ zddc/internal/zddc/lookups.go | 12 ++ 11 files changed, 616 insertions(+), 5 deletions(-) create mode 100644 zddc/internal/handler/mdhistory_test.go create mode 100644 zddc/internal/zddc/history_policy_test.go diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 84389c6..a99ac87 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // see RecognizeVirtualConvert). The .md source serves // normally here.) - // Record-history list: GET .yaml?history=1 returns the - // list of prior revisions stored under /.history//. - // ACL already passed (parent-dir chain). Non-record paths fall - // through to the normal file serve. - if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" { + // Edit-history: ACL already passed (parent-dir chain). + // - Records (.yaml rows): GET .yaml?history=1 lists prior + // revisions stored under /.history// (audit in-body). + // - Text (markdown) under a history: true subtree: + // ?history=1 lists versions; ?history= returns that version's + // bytes. Audit lives in /.history//log.jsonl. + // Non-history paths fall through to the normal file serve. + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") { + version := r.URL.Query().Get("history") + if handler.IsTextHistoryCandidate(absPath) { + if chain.EffectiveHistory() { + handler.ServeTextHistory(w, r, absPath, version) + } else { + http.NotFound(w, r) + } + return + } handler.ServeHistoryList(w, r, absPath) return } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index a3ee4b0..b898dc7 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -102,6 +102,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, principal := zddc.Principal{Email: userEmail, Elevated: elevated} parentActiveAdmin := elevated && userEmail != "" && zddc.IsAdminForChain(parentChain, userEmail) + // Edit-history is a subtree behavior; resolved once for this dir and + // flagged on each eligible (markdown) file so the browse client knows + // where to offer the History/diff affordances. + historyEnabled := parentChain.EffectiveHistory() for _, entry := range entries { name := entry.Name() @@ -189,6 +193,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, writableBit = zddc.VerbA } fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin + fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md") result = append(result, fi) } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index c0d70e3..31b376f 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -432,6 +432,16 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { } finalBody = res.FinalBody stamped = true + } else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) { + // History-enabled text (markdown) files: snapshot every save + // into /.history// with a server-stamped audit line, + // then write the live file. The live file at its natural path + // remains the source of truth. + if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil { + auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } } else { if err := zddc.WriteAtomic(abs, body); err != nil { auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index 5320057..46d2d45 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -802,3 +802,243 @@ func atoiSafe(s string) int { } return n } + +// ─── Markdown / text edit-history ──────────────────────────────────────── +// +// History-enabled text files (a `history: true` .zddc subtree — see +// zddc.PolicyChain.EffectiveHistory) keep every saved version under +// /.history//. Unlike records, text files can't carry audit +// fields in-body, so authorship + ordering live in a sidecar log: +// +// .history//.md one immutable blob per distinct content +// .history//log.jsonl one MdHistoryEntry per save, in order +// +// Blobs are content-addressed (so reverting to an earlier exact state +// reuses its blob); the live file's content always equals the last log +// entry's sha. Email + timestamp are stamped server-side from the +// authenticated principal — never client-supplied, mirroring the record +// path's anti-forgery stance. + +const mdHistoryLogName = "log.jsonl" + +// MdHistoryEntry is one saved version of a history-tracked text file. +type MdHistoryEntry struct { + Ts string `json:"ts"` // RFC3339Nano UTC of the save + By string `json:"by"` // authenticated principal email ("" if pre-history) + Sha string `json:"sha"` // content hash = version id = blob stem + Prev string `json:"prev,omitempty"` // prior version's sha + Bytes int `json:"bytes"` // size of this version + Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted +} + +// mdVersionIDRe validates a client-supplied version id so it can't +// escape the history dir. Version ids are lowercase hex content hashes. +var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`) + +// IsTextHistoryCandidate reports whether abs is a text file eligible for +// edit-history versioning. Scoped to markdown for now (the browse editor +// surface); widen here to add .txt etc. +func IsTextHistoryCandidate(abs string) bool { + return strings.EqualFold(filepath.Ext(abs), ".md") +} + +func mdHistoryDir(abs string) string { + return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs))) +} + +// WriteTextWithHistory snapshots prior and new content into +// .history//, appends an audit line, then writes the live file +// atomically. No-op saves (content identical to the current head) don't +// create a version. History is written BEFORE the live file so a crash +// can't lose a version the live write would have superseded. +func WriteTextWithHistory(abs string, body []byte, principalEmail string) error { + histDir := mdHistoryDir(abs) + logPath := filepath.Join(histDir, mdHistoryLogName) + ext := filepath.Ext(abs) + + // Prior live content (nil on create). + var prior []byte + priorSha := "" + priorMtime := "" + if info, err := os.Stat(abs); err == nil && !info.IsDir() { + if data, rerr := os.ReadFile(abs); rerr == nil { + prior = data + priorSha = fileETag(prior) + priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano) + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + newSha := fileETag(body) + if principalEmail == "" { + principalEmail = "anonymous" + } + now := time.Now().UTC().Format(time.RFC3339Nano) + + if err := os.MkdirAll(histDir, 0o755); err != nil { + return fmt.Errorf("mkdir history: %w", err) + } + + writeBlob := func(sha string, data []byte) error { + blob := filepath.Join(histDir, sha+ext) + if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) { + return zddc.WriteAtomic(blob, data) + } + return nil + } + + logExisted := false + if _, err := os.Stat(logPath); err == nil { + logExisted = true + } + + var entries []MdHistoryEntry + if logExisted { + existing, err := readMdLog(logPath) + if err != nil { + return err + } + entries = existing + } + + // Lazy-seed: a file that pre-existed history enablement has prior + // bytes but no log. Capture that state as the origin version so the + // chain isn't missing its start. Authorship is unknown (""). + if prior != nil && !logExisted { + if err := writeBlob(priorSha, prior); err != nil { + return err + } + ts := priorMtime + if ts == "" { + ts = now + } + entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)}) + } + + lastSha := "" + if len(entries) > 0 { + lastSha = entries[len(entries)-1].Sha + } + + // Record this save unless it's a no-op (identical to the head). + changed := newSha != lastSha + if changed { + if err := writeBlob(newSha, body); err != nil { + return err + } + entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)}) + } + + // Rewrite the log atomically (CIFS-safe; avoids partial-append + // corruption) only when something actually changed. + if changed || (prior != nil && !logExisted) { + if err := writeMdLog(logPath, entries); err != nil { + return err + } + } + + return zddc.WriteAtomic(abs, body) +} + +func readMdLog(path string) ([]MdHistoryEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var out []MdHistoryEntry + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var e MdHistoryEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue // skip a malformed line rather than fail the whole read + } + out = append(out, e) + } + return out, nil +} + +func writeMdLog(path string, entries []MdHistoryEntry) error { + var sb strings.Builder + for _, e := range entries { + e.Current = false // never persist the derived flag + b, err := json.Marshal(e) + if err != nil { + return err + } + sb.Write(b) + sb.WriteByte('\n') + } + return zddc.WriteAtomic(path, []byte(sb.String())) +} + +// ListMdHistory returns the saved versions of abs, newest first, with +// Current set on the version whose content matches the live file. +func ListMdHistory(abs string) ([]MdHistoryEntry, error) { + logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName) + entries, err := readMdLog(logPath) + if err != nil { + return nil, err + } + liveSha := "" + if data, err := os.ReadFile(abs); err == nil { + liveSha = fileETag(data) + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts }) + // Mark the newest entry whose content matches the live file as the + // current version. A revert reuses an earlier blob, so the same sha + // can appear twice — only the most recent save is "current". + for i := range entries { + if entries[i].Sha == liveSha { + entries[i].Current = true + break + } + } + return entries, nil +} + +// ServeTextHistory dispatches GET ?history=... for history-enabled +// text files: `?history=1` (or empty / `list`) returns the version list +// as JSON; `?history=` returns that version's raw bytes. ACL on the +// live file has already been checked by the caller. +func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) { + if !IsTextHistoryCandidate(abs) { + http.NotFound(w, r) + return + } + if version == "" || version == "1" || version == "list" { + entries, err := ListMdHistory(abs) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ZDDC-Source", "history-list") + _ = json.NewEncoder(w).Encode(entries) + return + } + if !mdVersionIDRe.MatchString(version) { + http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest) + return + } + blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs)) + data, err := os.ReadFile(blob) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("X-ZDDC-Source", "history-version") + w.Header().Set("X-ZDDC-History-Version", version) + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + _, _ = w.Write(data) +} diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go new file mode 100644 index 0000000..d5a91b6 --- /dev/null +++ b/zddc/internal/handler/mdhistory_test.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +func mustNoErr(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func countBlobs(t *testing.T, histDir string) int { + t.Helper() + ents, err := os.ReadDir(histDir) + if err != nil { + t.Fatalf("read history dir: %v", err) + } + n := 0 + for _, e := range ents { + if strings.HasSuffix(e.Name(), ".md") { + n++ + } + } + return n +} + +func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "notes.md") + histDir := filepath.Join(dir, ".history", "notes") + sha1 := fileETag([]byte("v1")) + sha2 := fileETag([]byte("v2")) + + // ── create ── + mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v1" { + t.Fatalf("live = %q, want v1", b) + } + entries, err := ListMdHistory(abs) + mustNoErr(t, err) + if len(entries) != 1 { + t.Fatalf("after create: want 1 entry, got %d", len(entries)) + } + if entries[0].By != "alice@x.com" { + t.Errorf("by = %q, want alice@x.com", entries[0].By) + } + if entries[0].Sha != sha1 { + t.Errorf("sha = %q, want %q", entries[0].Sha, sha1) + } + if !entries[0].Current { + t.Errorf("v1 should be current") + } + if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil { + t.Errorf("v1 blob missing: %v", err) + } + + // ── update ── + time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering + mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v2" { + t.Fatalf("live = %q, want v2", b) + } + entries, _ = ListMdHistory(abs) + if len(entries) != 2 { + t.Fatalf("after update: want 2 entries, got %d", len(entries)) + } + // newest first + if entries[0].Sha != sha2 || !entries[0].Current { + t.Errorf("head = %+v, want v2 current", entries[0]) + } + if entries[0].Prev != sha1 { + t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1) + } + if entries[1].Sha != sha1 || entries[1].Current { + t.Errorf("tail = %+v, want v1 non-current", entries[1]) + } + if entries[1].By != "alice@x.com" { + t.Errorf("v1 author lost: %q", entries[1].By) + } + + // ── no-op save (identical content) → dedup, no new entry ── + mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com")) + entries, _ = ListMdHistory(abs) + if len(entries) != 2 { + t.Fatalf("dedup failed: want 2 entries, got %d", len(entries)) + } + + // ── restore v1 content → new log entry, blob reused ── + time.Sleep(2 * time.Millisecond) + mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v1" { + t.Fatalf("live = %q, want restored v1", b) + } + entries, _ = ListMdHistory(abs) + if len(entries) != 3 { + t.Fatalf("after restore: want 3 entries, got %d", len(entries)) + } + if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" { + t.Errorf("head = %+v, want restored v1 by carol, current", entries[0]) + } + // Only the newest matching entry is current, even though the oldest + // entry has the same sha. + if entries[2].Current { + t.Errorf("oldest v1 entry should not be current: %+v", entries[2]) + } + // Content-addressed: only two distinct blobs (v1, v2) despite 3 saves. + if n := countBlobs(t, histDir); n != 2 { + t.Errorf("distinct blobs = %d, want 2", n) + } +} + +func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "doc.md") + // Simulate a file that existed before history was enabled. + mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy"))) + + mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com")) + + entries, err := ListMdHistory(abs) + mustNoErr(t, err) + if len(entries) != 2 { + t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries)) + } + // newest = the edit; oldest = the seeded legacy version (author unknown) + if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) { + t.Errorf("head = %+v, want edit by dave", entries[0]) + } + if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) { + t.Errorf("seed = %+v, want legacy with empty author", entries[1]) + } +} + +func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "x.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "")) + entries, _ := ListMdHistory(abs) + if len(entries) != 1 || entries[0].By != "anonymous" { + t.Fatalf("empty author should record anonymous, got %+v", entries) + } +} + +func TestServeTextHistory_ListAndVersion(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "page.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com")) + time.Sleep(2 * time.Millisecond) + mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com")) + sha1 := fileETag([]byte("one")) + + // ── list ── + req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, abs, "1") + if rec.Code != http.StatusOK { + t.Fatalf("list status = %d", rec.Code) + } + var got []MdHistoryEntry + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("list body not JSON: %v", err) + } + if len(got) != 2 || got[0].By != "b@x.com" { + t.Fatalf("list = %+v, want 2 newest-first", got) + } + + // ── specific version content ── + req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil) + rec = httptest.NewRecorder() + ServeTextHistory(rec, req, abs, sha1) + if rec.Code != http.StatusOK { + t.Fatalf("version status = %d", rec.Code) + } + if rec.Body.String() != "one" { + t.Errorf("version body = %q, want %q", rec.Body.String(), "one") + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") { + t.Errorf("content-type = %q", ct) + } +} + +func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "p.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com")) + // Drop a secret in the parent so a successful traversal would be visible. + mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET"))) + + for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} { + req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, abs, bad) + if rec.Code == http.StatusOK { + t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "TOPSECRET") { + t.Fatalf("traversal leaked secret for input %q", bad) + } + } + + // Non-markdown path → 404 (history not applicable). + yamlAbs := filepath.Join(dir, "rec.yaml") + req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, yamlAbs, "1") + if rec.Code != http.StatusNotFound { + t.Errorf("non-md status = %d, want 404", rec.Code) + } +} diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 345eadf..ee11e26 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -86,4 +86,12 @@ type FileInfo struct { // as "no permissions known" and fall back to a server round-trip // (or just disable affordances) rather than assuming any grant. Verbs string `json:"verbs,omitempty"` + + // History is true when this entry is a text file in a history: true + // cascade subtree — i.e. every save is versioned into the sibling + // .history/ store and GET ?history=1 lists prior versions. The + // browse client uses it to show "History…" / diff affordances only + // where they apply. False/absent for directories, virtual entries, + // and files outside a history-enabled subtree. + History bool `json:"history,omitempty"` } diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index afe973d..9d4cf90 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -47,6 +47,25 @@ func (chain PolicyChain) VisibleStart(toIdx int) int { return 0 } +// EffectiveHistory reports whether edit-history versioning is enabled +// for writes at this chain's directory. Unlike DropTarget (leaf-only), +// history is a subtree behavior: the closest-to-leaf explicit setting +// wins and applies to all descendants. It deliberately IGNORES +// inherit:false ACL fences — versioning is a write behavior, not a +// permission, so a fenced per-user home under a history-enabled +// working/ still records history. Falls back to the embedded defaults. +func (chain PolicyChain) EffectiveHistory() bool { + for i := len(chain.Levels) - 1; i >= 0; i-- { + if v := chain.Levels[i].History; v != nil { + return *v + } + } + if v := chain.Embedded.History; v != nil { + return *v + } + return false +} + // policyCache caches effective policies keyed by dirPath. // Values are PolicyChain. var policyCache sync.Map @@ -370,6 +389,7 @@ func nonZeroZddcFields(zf ZddcFile) []string { add("auto_own_fenced", zf.AutoOwnFenced != nil) add("virtual", zf.Virtual != nil) add("drop_target", zf.DropTarget != nil) + add("history", zf.History != nil) add("worm", zf.Worm != nil) add("available_tools", len(zf.AvailableTools) > 0) add("received_path", zf.ReceivedPath != "") diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 3863f08..4cffa80 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -475,6 +475,13 @@ paths: # homes below. auto_own: true drop_target: true + # Edit-history: every markdown save under working/ (incl. + # the fenced per-user homes — history inherits through + # fences) is versioned into a sibling .history/ store with + # a server-stamped audit line (who + when). The live file + # stays the source of truth; GET ?history lists prior + # versions. See ZddcFile.History / handler.WriteTextWithHistory. + history: true paths: "*": # per-user home dir, fenced default_tool: browse diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index e25b13f..ad6d82b 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -263,6 +263,24 @@ type ZddcFile struct { // not its descendants. Defaults (nil): no drop zone. DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` + // History enables server-managed edit-history versioning for text + // (markdown) writes in this subtree. When true, each save of a + // history-eligible file (see handler.IsTextHistoryCandidate) snapshots + // the content into /.history// and appends a server-stamped + // audit line (timestamp + authenticated email) to that folder's + // log.jsonl, then writes the live file. The live file at its natural + // path stays the source of truth; the .history/ store is immutable and + // auto-hidden (dot-prefixed). + // + // Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a + // `history: true` at an ancestor (e.g. archive//working/) + // applies to every descendant, and it deliberately inherits through + // inherit:false ACL fences (per-user homes under working/ still record + // history) — versioning is a write behavior, not a permission. A + // deeper level may set `history: false` to opt a subtree out. Resolved + // by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade. + History *bool `yaml:"history,omitempty" json:"history,omitempty"` + // Worm marks this directory (and its descendants) as // write-once-read-many. A non-nil Worm list — even an empty one — // puts the path into a WORM zone with these effects, applied AFTER diff --git a/zddc/internal/zddc/history_policy_test.go b/zddc/internal/zddc/history_policy_test.go new file mode 100644 index 0000000..c08cb80 --- /dev/null +++ b/zddc/internal/zddc/history_policy_test.go @@ -0,0 +1,58 @@ +package zddc + +import "testing" + +func bptr(b bool) *bool { return &b } + +func TestEffectiveHistory(t *testing.T) { + tests := []struct { + name string + chain PolicyChain + want bool + }{ + { + name: "no levels, no embedded default", + chain: PolicyChain{}, + want: false, + }, + { + name: "embedded default true, no explicit level", + chain: PolicyChain{Levels: []ZddcFile{{}, {}}, Embedded: ZddcFile{History: bptr(true)}}, + want: true, + }, + { + name: "ancestor true inherits down to leaf (nil)", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {}, {}}}, + want: true, + }, + { + name: "leaf false overrides ancestor true", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {History: bptr(false)}}}, + want: false, + }, + { + name: "leaf true overrides embedded false", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}}, Embedded: ZddcFile{History: bptr(false)}}, + want: true, + }, + { + // History must inherit THROUGH an inherit:false ACL fence — + // versioning is a write behavior, not a permission. The fenced + // middle level sets no History, so the ancestor's true wins. + name: "inherits through an inherit:false fence", + chain: PolicyChain{Levels: []ZddcFile{ + {History: bptr(true)}, + {ACL: ACLRules{Inherit: bptr(false)}}, + {}, + }}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.chain.EffectiveHistory(); got != tc.want { + t.Errorf("EffectiveHistory() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index cd1083c..bca0398 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -147,6 +147,18 @@ func VirtualAt(fsRoot, dirPath string) bool { return false } +// HistoryAt reports whether edit-history versioning is enabled for +// writes in dirPath. Subtree-inheriting (see +// PolicyChain.EffectiveHistory) — a `history: true` at an ancestor +// applies here even through inherit:false fences. +func HistoryAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + return chain.EffectiveHistory() +} + // IsDeclaredPath reports whether dirPath is mentioned in the // cascade — either by an on-disk .zddc at that level OR by any // ancestor's paths: tree (including the embedded defaults).