feat(history): self-describing per-save snapshots + readable-when-disabled + mdl/rsk/working defaults
Redesign the markdown edit-history store from content-hashed blobs + log.jsonl to one self-describing file per save: .history/<stem>/<ts>-<email>.<ext> The filename IS the audit (colon-free UTC timestamp valid on SMB/Azure Files + the authoring email); listing the directory is the history. No sidecar log, no hashing. A byte-identical save is a no-op; a pre-existing file lazy-seeds its current bytes (author "unknown", stamped at mtime). Reverting copies an old snapshot back (records as a fresh save). Snapshots are kept forever. Fixes the 404 reading history: reads no longer require history to be *currently* enabled — ServeTextHistory serves whatever .history/<stem>/ exists (empty list when none); the dispatch drops the EffectiveHistory gate for reads. WRITES stay gated by the history: flag. (The 404 came from the aggregator refactor turning history off on project-level working/, which made already-recorded snapshots unreadable.) Renames: an in-place rename carries .history/<stem>/ to the new name (serveFileMove); a cross-dir move leaves it behind. Defaults: history: true now ships on the three live-editing slots — working, mdl, rsk — at both the project-level nodes and the per-party folders. It's a .zddc cascade key, so operators override per project. Records (.yaml in mdl/rsk) keep their separate record-history path. Browse history viewer updated to the filename-based version id (id ← sha). Tests rewritten for the per-file scheme + rename behavior + SMB-safe names; HistoryAt defaults test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
28ebaa19cd
commit
7ff78ef254
8 changed files with 330 additions and 209 deletions
|
|
@ -6,8 +6,8 @@
|
|||
// equivalent.
|
||||
//
|
||||
// Talks to the zddc-server history endpoints on the file's own URL:
|
||||
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
|
||||
// GET <url>?history=<sha> → that version's raw bytes
|
||||
// GET <url>?history=1 → JSON [{ts, by, id, bytes, current}]
|
||||
// GET <url>?history=<id> → that version's raw bytes (id = snapshot filename)
|
||||
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
||||
// records as a new version (forward-only; never destructive).
|
||||
//
|
||||
|
|
@ -67,8 +67,8 @@
|
|||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function fetchVersion(node, sha) {
|
||||
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
|
||||
async function fetchVersion(node, id) {
|
||||
var resp = await fetch(histURL(node.url, id), { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.text();
|
||||
}
|
||||
|
|
@ -171,17 +171,17 @@
|
|||
cb.className = 'md-history-pick';
|
||||
cb.addEventListener('change', function () {
|
||||
if (cb.checked) {
|
||||
selected.push(ent.sha);
|
||||
selected.push(ent.id);
|
||||
// Keep at most two: drop the oldest selection.
|
||||
if (selected.length > 2) {
|
||||
var dropped = selected.shift();
|
||||
var others = list.querySelectorAll('.md-history-pick');
|
||||
others.forEach(function (o, i) {
|
||||
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
|
||||
if (o !== cb && entries[i] && entries[i].id === dropped) o.checked = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selected = selected.filter(function (s) { return s !== ent.sha; });
|
||||
selected = selected.filter(function (s) { return s !== ent.id; });
|
||||
}
|
||||
syncDiffBtn();
|
||||
});
|
||||
|
|
@ -220,7 +220,7 @@
|
|||
if (selected.length !== 2) return;
|
||||
// Order oldest→newest by the entries' position (newest
|
||||
// first in the list), so the diff reads old → new.
|
||||
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
|
||||
var picks = entries.filter(function (e) { return selected.indexOf(e.id) !== -1; });
|
||||
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
||||
renderDiff(modal, node, picks[0], picks[1], entries);
|
||||
}
|
||||
|
|
@ -237,7 +237,7 @@
|
|||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||||
var text;
|
||||
try {
|
||||
text = await fetchVersion(node, ent.sha);
|
||||
text = await fetchVersion(node, ent.id);
|
||||
} catch (e) {
|
||||
body.innerHTML = '';
|
||||
var err = document.createElement('p');
|
||||
|
|
@ -274,8 +274,8 @@
|
|||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||||
var oldText, newText;
|
||||
try {
|
||||
oldText = await fetchVersion(node, oldEnt.sha);
|
||||
newText = await fetchVersion(node, newEnt.sha);
|
||||
oldText = await fetchVersion(node, oldEnt.id);
|
||||
newText = await fetchVersion(node, newEnt.id);
|
||||
} catch (e) {
|
||||
body.innerHTML = '';
|
||||
var err = document.createElement('p');
|
||||
|
|
@ -347,7 +347,7 @@
|
|||
return;
|
||||
}
|
||||
try {
|
||||
var text = await fetchVersion(node, ent.sha);
|
||||
var text = await fetchVersion(node, ent.id);
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ func main() {
|
|||
slog.Info("archive periodic rescan disabled (interval=0)")
|
||||
}
|
||||
|
||||
|
||||
// HTTP handler
|
||||
mux := http.NewServeMux()
|
||||
// Middleware chain (outermost → innermost):
|
||||
|
|
@ -1333,11 +1332,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
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() {
|
||||
// Reading recorded history does NOT require history to be
|
||||
// currently enabled — snapshots already on disk stay readable
|
||||
// (empty list when there are none) even if the `history:` flag
|
||||
// was later turned off. The file's read ACL was already checked
|
||||
// above; WRITES remain gated by EffectiveHistory in serveFilePut.
|
||||
handler.ServeTextHistory(w, r, absPath, version)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
handler.ServeHistoryList(w, r, absPath)
|
||||
|
|
|
|||
|
|
@ -686,6 +686,24 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
purgeConverted(srcAbs)
|
||||
purgeConverted(dstAbs)
|
||||
|
||||
// Carry edit-history across an in-place rename: if a markdown file was
|
||||
// renamed within the same directory, move its .history/<stem>/ folder to
|
||||
// match the new name. A cross-directory move deliberately leaves history
|
||||
// behind (it lives forever in the dir where the edits happened).
|
||||
if IsTextHistoryCandidate(srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
|
||||
oldHist := mdHistoryDir(srcAbs)
|
||||
newHist := mdHistoryDir(dstAbs)
|
||||
if oldHist != newHist {
|
||||
if _, err := os.Stat(oldHist); err == nil {
|
||||
if _, derr := os.Stat(newHist); errors.Is(derr, os.ErrNotExist) {
|
||||
if rerr := os.Rename(oldHist, newHist); rerr != nil {
|
||||
slog.Warn("rename history dir", "from", oldHist, "to", newHist, "err", rerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute new ETag from the moved bytes for the response — clients
|
||||
// that want to keep tracking should pin to this ETag.
|
||||
if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
|
||||
|
|
|
|||
|
|
@ -751,3 +751,52 @@ func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
|||
t.Errorf("party-scoped folder not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An in-place rename of a markdown file carries its .history/<stem>/ folder
|
||||
// to the new name; a cross-directory move leaves history behind.
|
||||
func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
||||
"Docs/notes.md": "# notes\n",
|
||||
})
|
||||
// Pre-seed a history snapshot for notes.md.
|
||||
histOld := filepath.Join(root, "Docs", ".history", "notes")
|
||||
if err := os.MkdirAll(histOld, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(histOld, "20260101T000000.000Z-a@example.com.md"), []byte("# notes\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// In-place rename → history dir follows.
|
||||
rec := do(http.MethodPost, "/Docs/notes.md", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "move",
|
||||
"X-ZDDC-Destination": "/Docs/renamed.md",
|
||||
})
|
||||
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 {
|
||||
t.Errorf("history dir not renamed to <newstem>: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(histOld); !os.IsNotExist(err) {
|
||||
t.Errorf("old history dir should be gone after rename; err=%v", err)
|
||||
}
|
||||
|
||||
// Cross-directory move → history stays behind in the source dir.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Other"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = do(http.MethodPost, "/Docs/renamed.md", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "move",
|
||||
"X-ZDDC-Destination": "/Other/renamed.md",
|
||||
})
|
||||
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 {
|
||||
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) {
|
||||
t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
|
@ -806,38 +807,38 @@ func atoiSafe(s string) int {
|
|||
// ─── 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:
|
||||
// zddc.PolicyChain.EffectiveHistory) drop one self-describing snapshot per
|
||||
// save under <dir>/.history/<stem>/:
|
||||
//
|
||||
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
|
||||
// .history/<stem>/log.jsonl one MdHistoryEntry per save, in order
|
||||
// .history/<stem>/<ts>-<email>.<ext> one file per save
|
||||
//
|
||||
// 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.
|
||||
// The filename IS the audit record: <ts> is a colon-free UTC timestamp
|
||||
// (valid on SMB / Azure Files) and <email> is the authenticated principal.
|
||||
// No sidecar log, no content hashing — listing the directory is the
|
||||
// history. Reverting copies an old snapshot's bytes onto the live file
|
||||
// (which then records as a fresh save). Timestamp + email are stamped
|
||||
// server-side, never client-supplied. Snapshots are kept forever: a doc
|
||||
// rename renames its <stem>/ folder (see the move handler); a delete or a
|
||||
// move out of the working dir leaves the history behind.
|
||||
|
||||
const mdHistoryLogName = "log.jsonl"
|
||||
// mdStampLayout formats the save time WITHOUT colons or hyphens so the
|
||||
// name stays valid on SMB/Windows shares AND splits cleanly on the first
|
||||
// '-' into <ts>-<email>. A literal "Z" is appended separately (a bare "Z"
|
||||
// is not a Go time token). The fixed width sorts lexically by time.
|
||||
const mdStampLayout = "20060102T150405.000"
|
||||
|
||||
// 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
|
||||
Ts string `json:"ts"` // RFC3339 UTC of the save (parsed from the filename)
|
||||
By string `json:"by"` // authoring principal email ("unknown" if pre-history)
|
||||
ID string `json:"id"` // the snapshot filename — opaque version id for ?history=<id>
|
||||
Bytes int64 `json:"bytes"` // size of this version
|
||||
Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file
|
||||
}
|
||||
|
||||
// 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.
|
||||
// edit-history versioning. Scoped to markdown (the browse editor surface);
|
||||
// widen here to add .txt etc.
|
||||
func IsTextHistoryCandidate(abs string) bool {
|
||||
return strings.EqualFold(filepath.Ext(abs), ".md")
|
||||
}
|
||||
|
|
@ -846,161 +847,178 @@ 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.
|
||||
// mdStamp renders t as the colon-free snapshot timestamp with the trailing
|
||||
// Z, e.g. 20260602T143000.123Z.
|
||||
func mdStamp(t time.Time) string {
|
||||
return t.UTC().Format(mdStampLayout) + "Z"
|
||||
}
|
||||
|
||||
// sanitizeForFilename keeps a principal email filesystem-safe. Emails are
|
||||
// already SMB-safe (@ . - _ +); only a path separator or control char would
|
||||
// be a problem, so collapse anything outside that set to '_'. Empty → the
|
||||
// "unknown" sentinel used for pre-history seed snapshots.
|
||||
func sanitizeForFilename(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '@', r == '.', r == '_', r == '+', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseHistoryName splits a snapshot filename "<ts>-<email>.<ext>" into a
|
||||
// display timestamp (RFC3339) and the author email. ok is false for names
|
||||
// that don't match the scheme (so foreign files in the dir are ignored).
|
||||
// The ts field has no '-', so the first '-' is the ts/email boundary; the
|
||||
// email may itself contain '-' (e.g. a hyphenated domain).
|
||||
func parseHistoryName(name string) (tsRFC3339, email string, ok bool) {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
dash := strings.IndexByte(stem, '-')
|
||||
if dash <= 0 || dash == len(stem)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
tsRaw := strings.TrimSuffix(stem[:dash], "Z")
|
||||
t, err := time.Parse(mdStampLayout, tsRaw)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339), stem[dash+1:], true
|
||||
}
|
||||
|
||||
// WriteTextWithHistory drops a snapshot of the new content into
|
||||
// .history/<stem>/<ts>-<email>.<ext>, then writes the live file. A save
|
||||
// byte-identical to the live file is a no-op (no snapshot, no rewrite). A
|
||||
// file that pre-existed history enablement is lazy-seeded: its current
|
||||
// bytes are captured as an origin snapshot (stamped with the file's mtime,
|
||||
// author "unknown") before the new one. The snapshot is written BEFORE the
|
||||
// live file so a crash can't lose a version the live write 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 := ""
|
||||
var priorMtime time.Time
|
||||
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)
|
||||
priorMtime = info.ModTime().UTC()
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
newSha := fileETag(body)
|
||||
if principalEmail == "" {
|
||||
principalEmail = "anonymous"
|
||||
// No-op: identical to the live file → nothing to record or rewrite.
|
||||
if prior != nil && bytes.Equal(prior, body) {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
// Lazy-seed the pre-history origin version (no snapshots yet but the
|
||||
// file already has content).
|
||||
if prior != nil && historyDirEmpty(histDir) {
|
||||
seedAt := priorMtime
|
||||
if seedAt.IsZero() {
|
||||
seedAt = time.Now().UTC()
|
||||
}
|
||||
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 {
|
||||
if err := writeHistorySnapshot(histDir, seedAt, "", ext, prior); 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 {
|
||||
if err := writeHistorySnapshot(histDir, time.Now().UTC(), principalEmail, ext, body); 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 {
|
||||
// writeHistorySnapshot writes data to <histDir>/<ts>-<email><ext>. On a
|
||||
// name collision (same millisecond + author) it steps the timestamp
|
||||
// forward until a free name is found, so each save keeps a distinct file.
|
||||
func writeHistorySnapshot(histDir string, t time.Time, email, ext string, data []byte) error {
|
||||
who := sanitizeForFilename(email)
|
||||
for i := 0; i < 1000; i++ {
|
||||
p := filepath.Join(histDir, mdStamp(t)+"-"+who+ext)
|
||||
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||
return zddc.WriteAtomic(p, data)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.Write(b)
|
||||
sb.WriteByte('\n')
|
||||
t = t.Add(time.Millisecond)
|
||||
}
|
||||
return zddc.WriteAtomic(path, []byte(sb.String()))
|
||||
return fmt.Errorf("history: no free snapshot name in %s", histDir)
|
||||
}
|
||||
|
||||
// historyDirEmpty reports whether histDir holds no snapshot files (missing
|
||||
// dir counts as empty).
|
||||
func historyDirEmpty(histDir string) bool {
|
||||
entries, err := os.ReadDir(histDir)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ListMdHistory returns the saved versions of abs, newest first, with
|
||||
// Current set on the version whose content matches the live file.
|
||||
// Current set on the version whose bytes match the live file.
|
||||
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
|
||||
logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName)
|
||||
entries, err := readMdLog(logPath)
|
||||
histDir := mdHistoryDir(abs)
|
||||
dirEntries, err := os.ReadDir(histDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []MdHistoryEntry{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
liveSha := ""
|
||||
if data, err := os.ReadFile(abs); err == nil {
|
||||
liveSha = fileETag(data)
|
||||
out := []MdHistoryEntry{}
|
||||
for _, de := range dirEntries {
|
||||
if de.IsDir() {
|
||||
continue
|
||||
}
|
||||
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
|
||||
ts, by, ok := parseHistoryName(de.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
if info, ierr := de.Info(); ierr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
out = append(out, MdHistoryEntry{Ts: ts, By: by, ID: de.Name(), Bytes: size})
|
||||
}
|
||||
// Newest first — the id (filename) sorts lexically by its ts prefix.
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
// Mark the newest snapshot whose bytes match the live file as current.
|
||||
// Size-gate before reading so we don't slurp every version.
|
||||
if live, lerr := os.ReadFile(abs); lerr == nil {
|
||||
for i := range out {
|
||||
if out[i].Bytes != int64(len(live)) {
|
||||
continue
|
||||
}
|
||||
if data, rerr := os.ReadFile(filepath.Join(histDir, out[i].ID)); rerr == nil && bytes.Equal(data, live) {
|
||||
out[i].Current = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ServeTextHistory dispatches GET <file>?history=... for history-enabled
|
||||
|
|
@ -1023,12 +1041,19 @@ func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version strin
|
|||
_ = json.NewEncoder(w).Encode(entries)
|
||||
return
|
||||
}
|
||||
if !mdVersionIDRe.MatchString(version) {
|
||||
// version is a snapshot filename. Reject anything that could escape the
|
||||
// history dir, then require it to resolve to a file strictly inside it.
|
||||
if version == "." || version == ".." || strings.ContainsAny(version, "/\\") || strings.Contains(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)
|
||||
histDir := mdHistoryDir(abs)
|
||||
snap := filepath.Join(histDir, version)
|
||||
if snap != filepath.Clean(snap) || !strings.HasPrefix(snap, histDir+string(filepath.Separator)) {
|
||||
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(snap)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ func mustNoErr(t *testing.T, err error) {
|
|||
}
|
||||
}
|
||||
|
||||
func countBlobs(t *testing.T, histDir string) int {
|
||||
// countSnapshots counts the per-save history files in histDir.
|
||||
func countSnapshots(t *testing.T, histDir string) int {
|
||||
t.Helper()
|
||||
ents, err := os.ReadDir(histDir)
|
||||
if err != nil {
|
||||
|
|
@ -40,10 +41,8 @@ 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 ──
|
||||
// ── create: one snapshot, authored, current ──
|
||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
|
||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
||||
t.Fatalf("live = %q, want v1", b)
|
||||
|
|
@ -56,18 +55,18 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
|||
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)
|
||||
if entries[0].ID == "" || !strings.HasSuffix(entries[0].ID, "-alice@x.com.md") {
|
||||
t.Errorf("id = %q, want a <ts>-alice@x.com.md snapshot name", entries[0].ID)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(histDir, entries[0].ID)); err != nil {
|
||||
t.Errorf("v1 snapshot missing: %v", err)
|
||||
}
|
||||
|
||||
// ── update ──
|
||||
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering
|
||||
// ── update: second snapshot, newest-first, current moves ──
|
||||
time.Sleep(2 * time.Millisecond) // distinct timestamp 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)
|
||||
|
|
@ -76,28 +75,20 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
|||
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].By != "bob@x.com" || !entries[0].Current {
|
||||
t.Errorf("head = %+v, want v2 by bob, 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)
|
||||
if entries[1].By != "alice@x.com" || entries[1].Current {
|
||||
t.Errorf("tail = %+v, want v1 by alice, non-current", entries[1])
|
||||
}
|
||||
|
||||
// ── no-op save (identical content) → dedup, no new entry ──
|
||||
// ── no-op save (identical to live) → no new snapshot ──
|
||||
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))
|
||||
if n := countSnapshots(t, histDir); n != 2 {
|
||||
t.Fatalf("dedup failed: want 2 snapshots, got %d", n)
|
||||
}
|
||||
|
||||
// ── restore v1 content → new log entry, blob reused ──
|
||||
// ── restore v1 content → a NEW snapshot (every save is its own file) ──
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
|
||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
||||
|
|
@ -107,17 +98,16 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
|||
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" {
|
||||
if entries[0].By != "carol@x.com" || !entries[0].Current {
|
||||
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.
|
||||
// Only the newest matching-content entry is current, even though the
|
||||
// oldest snapshot has the same bytes.
|
||||
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)
|
||||
if n := countSnapshots(t, histDir); n != 3 {
|
||||
t.Errorf("snapshots = %d, want 3 (one file per save)", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +116,7 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
|
|||
abs := filepath.Join(dir, "doc.md")
|
||||
// Simulate a file that existed before history was enabled.
|
||||
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
|
||||
time.Sleep(2 * time.Millisecond) // keep the seed's mtime stamp < the edit
|
||||
|
||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
|
||||
|
||||
|
|
@ -135,21 +126,21 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
|
|||
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")) {
|
||||
if entries[0].By != "dave@x.com" {
|
||||
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])
|
||||
if entries[1].By != "unknown" {
|
||||
t.Errorf("seed = %+v, want legacy with 'unknown' author", entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) {
|
||||
func TestWriteTextWithHistory_EmptyAuthorUnknown(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)
|
||||
if len(entries) != 1 || entries[0].By != "unknown" {
|
||||
t.Fatalf("empty author should record 'unknown', got %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +150,6 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
|
|||
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)
|
||||
|
|
@ -176,10 +166,11 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
|
|||
t.Fatalf("list = %+v, want 2 newest-first", got)
|
||||
}
|
||||
|
||||
// ── specific version content ──
|
||||
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
|
||||
// ── specific version content (oldest = "one") ──
|
||||
oldID := got[1].ID
|
||||
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil)
|
||||
rec = httptest.NewRecorder()
|
||||
ServeTextHistory(rec, req, abs, sha1)
|
||||
ServeTextHistory(rec, req, abs, oldID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("version status = %d", rec.Code)
|
||||
}
|
||||
|
|
@ -198,7 +189,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
|||
// 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"} {
|
||||
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeTextHistory(rec, req, abs, bad)
|
||||
|
|
@ -210,7 +201,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Non-markdown path → 404 (history not applicable).
|
||||
// Non-markdown path → 404 (text history not applicable).
|
||||
yamlAbs := filepath.Join(dir, "rec.yaml")
|
||||
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -219,3 +210,19 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
|||
t.Errorf("non-md status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteTextWithHistory_RenameDirOnMove is covered at the handler level
|
||||
// (serveFileMove); here we only assert the snapshot filenames are SMB-safe
|
||||
// (no colons) so they're valid on the Azure Files share.
|
||||
func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
abs := filepath.Join(dir, "n.md")
|
||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "cwitt@burnsmcd.com"))
|
||||
entries, _ := ListMdHistory(abs)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("want 1 entry, got %d", len(entries))
|
||||
}
|
||||
if strings.ContainsAny(entries[0].ID, ":\\/") {
|
||||
t.Errorf("snapshot id %q contains a char invalid on SMB", entries[0].ID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,10 +199,15 @@ paths:
|
|||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
# Edit-history default-on for the deliverables list (subtree-
|
||||
# inheriting; see working/ note). Operators override per .zddc.
|
||||
history: true
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
# Edit-history default-on for the risk register.
|
||||
history: true
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
|
|
@ -213,6 +218,12 @@ paths:
|
|||
# folder" picker) and lands it at archive/<party>/working/<name>,
|
||||
# which carries its own history: true + auto-own convention.
|
||||
virtual: true
|
||||
# Edit-history default-on across the working subtree (markdown
|
||||
# saves are snapshotted to .history/<stem>/). Subtree-inheriting,
|
||||
# so it also covers any pre-reshape <project>/working/<…> homes
|
||||
# that still hold content. Reads of recorded history never require
|
||||
# this flag — turning it off only stops new snapshots.
|
||||
history: true
|
||||
staging:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
|
|
@ -309,6 +320,10 @@ paths:
|
|||
# tables tool serves it from the embedded default
|
||||
# spec even when the on-disk folder doesn't exist.
|
||||
virtual: true
|
||||
# Edit-history default-on (markdown notes/specs saved here
|
||||
# are snapshotted; .yaml records keep their own record-
|
||||
# history path regardless).
|
||||
history: true
|
||||
# MDL records: each .yaml file is an independent
|
||||
# deliverable with its own composed tracking number.
|
||||
# No type lock — the row's body fields drive the
|
||||
|
|
@ -348,6 +363,8 @@ paths:
|
|||
# as mdl/. Embedded default-rsk spec backs it when no
|
||||
# operator override is on disk.
|
||||
virtual: true
|
||||
# Edit-history default-on (same as mdl/).
|
||||
history: true
|
||||
# RSK records: each .yaml file is a row of a parent
|
||||
# rsk-type deliverable. The table itself has a tracking
|
||||
# number (same default components as an MDL deliverable
|
||||
|
|
|
|||
|
|
@ -61,17 +61,22 @@ func TestHistoryAt_Defaults(t *testing.T) {
|
|||
path string
|
||||
want bool
|
||||
}{
|
||||
// Project-level working/ is a pure virtual aggregator — no
|
||||
// direct content, so no history there.
|
||||
{filepath.Join(root, "Project-X", "working"), false},
|
||||
// Per-party working carries history (edit-history versioning).
|
||||
// Edit-history defaults on for the three live-editing slots:
|
||||
// working, mdl, rsk — at both the project-level virtual nodes and
|
||||
// the per-party folders (subtree-inheriting).
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
|
||||
// Sibling slots get no history.
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
||||
// Other slots get no history.
|
||||
{filepath.Join(root, "Project-X", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
||||
{filepath.Join(root, "Project-X", "ssr"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
|
|
|
|||
Loading…
Reference in a new issue