feat(server): edit-history versioning for working-folder markdown
A history: true .zddc subtree (enabled by default on archive/<party>/working/)
routes markdown PUTs through WriteTextWithHistory: each save snapshots the
content into a hidden, immutable .history/<stem>/ 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 <file>?history=1 lists versions (newest-first, current flagged);
GET <file>?history=<sha> 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) <noreply@anthropic.com>
This commit is contained in:
parent
de046360e6
commit
6efe71e573
11 changed files with 616 additions and 5 deletions
|
|
@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// see RecognizeVirtualConvert). The .md source serves
|
// see RecognizeVirtualConvert). The .md source serves
|
||||||
// normally here.)
|
// normally here.)
|
||||||
|
|
||||||
// Record-history list: GET <record>.yaml?history=1 returns the
|
// Edit-history: ACL already passed (parent-dir chain).
|
||||||
// list of prior revisions stored under <dir>/.history/<base>/.
|
// - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
|
||||||
// ACL already passed (parent-dir chain). Non-record paths fall
|
// revisions stored under <dir>/.history/<base>/ (audit in-body).
|
||||||
// through to the normal file serve.
|
// - Text (markdown) under a history: true subtree:
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" {
|
// ?history=1 lists versions; ?history=<sha> returns that version's
|
||||||
|
// bytes. Audit lives in <dir>/.history/<stem>/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)
|
handler.ServeHistoryList(w, r, absPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
||||||
parentActiveAdmin := elevated && userEmail != "" &&
|
parentActiveAdmin := elevated && userEmail != "" &&
|
||||||
zddc.IsAdminForChain(parentChain, 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 {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
@ -189,6 +193,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
writableBit = zddc.VerbA
|
writableBit = zddc.VerbA
|
||||||
}
|
}
|
||||||
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
||||||
|
fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md")
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,16 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
finalBody = res.FinalBody
|
finalBody = res.FinalBody
|
||||||
stamped = true
|
stamped = true
|
||||||
|
} else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
|
// History-enabled text (markdown) files: snapshot every save
|
||||||
|
// into <dir>/.history/<stem>/ 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 {
|
} else {
|
||||||
if err := zddc.WriteAtomic(abs, body); err != nil {
|
if err := zddc.WriteAtomic(abs, body); err != nil {
|
||||||
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
||||||
|
|
|
||||||
|
|
@ -802,3 +802,243 @@ func atoiSafe(s string) int {
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Markdown / text edit-history ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// History-enabled text files (a `history: true` .zddc subtree — see
|
||||||
|
// zddc.PolicyChain.EffectiveHistory) keep every saved version under
|
||||||
|
// <dir>/.history/<stem>/. Unlike records, text files can't carry audit
|
||||||
|
// fields in-body, so authorship + ordering live in a sidecar log:
|
||||||
|
//
|
||||||
|
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
|
||||||
|
// .history/<stem>/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/<stem>/, 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 <file>?history=... for history-enabled
|
||||||
|
// text files: `?history=1` (or empty / `list`) returns the version list
|
||||||
|
// as JSON; `?history=<sha>` 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
221
zddc/internal/handler/mdhistory_test.go
Normal file
221
zddc/internal/handler/mdhistory_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,4 +86,12 @@ type FileInfo struct {
|
||||||
// as "no permissions known" and fall back to a server round-trip
|
// as "no permissions known" and fall back to a server round-trip
|
||||||
// (or just disable affordances) rather than assuming any grant.
|
// (or just disable affordances) rather than assuming any grant.
|
||||||
Verbs string `json:"verbs,omitempty"`
|
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 <url>?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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,25 @@ func (chain PolicyChain) VisibleStart(toIdx int) int {
|
||||||
return 0
|
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.
|
// policyCache caches effective policies keyed by dirPath.
|
||||||
// Values are PolicyChain.
|
// Values are PolicyChain.
|
||||||
var policyCache sync.Map
|
var policyCache sync.Map
|
||||||
|
|
@ -370,6 +389,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
||||||
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
||||||
add("virtual", zf.Virtual != nil)
|
add("virtual", zf.Virtual != nil)
|
||||||
add("drop_target", zf.DropTarget != nil)
|
add("drop_target", zf.DropTarget != nil)
|
||||||
|
add("history", zf.History != nil)
|
||||||
add("worm", zf.Worm != nil)
|
add("worm", zf.Worm != nil)
|
||||||
add("available_tools", len(zf.AvailableTools) > 0)
|
add("available_tools", len(zf.AvailableTools) > 0)
|
||||||
add("received_path", zf.ReceivedPath != "")
|
add("received_path", zf.ReceivedPath != "")
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,13 @@ paths:
|
||||||
# homes below.
|
# homes below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: 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 <file>?history lists prior
|
||||||
|
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
|
||||||
|
history: true
|
||||||
paths:
|
paths:
|
||||||
"*": # per-user home dir, fenced
|
"*": # per-user home dir, fenced
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,24 @@ type ZddcFile struct {
|
||||||
// not its descendants. Defaults (nil): no drop zone.
|
// not its descendants. Defaults (nil): no drop zone.
|
||||||
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
|
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 <dir>/.history/<stem>/ 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/<party>/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
|
// Worm marks this directory (and its descendants) as
|
||||||
// write-once-read-many. A non-nil Worm list — even an empty one —
|
// 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
|
// puts the path into a WORM zone with these effects, applied AFTER
|
||||||
|
|
|
||||||
58
zddc/internal/zddc/history_policy_test.go
Normal file
58
zddc/internal/zddc/history_policy_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,18 @@ func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
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
|
// IsDeclaredPath reports whether dirPath is mentioned in the
|
||||||
// cascade — either by an on-disk .zddc at that level OR by any
|
// cascade — either by an on-disk .zddc at that level OR by any
|
||||||
// ancestor's paths: tree (including the embedded defaults).
|
// ancestor's paths: tree (including the embedded defaults).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue