Skip to main content

filesnap/
refs.rs

1//! Per-session snapshot logs and garbage collection.
2//!
3//! Each thread has an append-only log of `(turn_id, manifest_id)` refs.
4//! Snapshot lifetime is tied to thread lifetime: removing a thread drops
5//! its refs, and a mark-and-sweep pass then deletes unreferenced
6//! manifests and blobs. No timers, no retention windows.
7
8use std::collections::BTreeSet;
9use std::fs;
10use std::path::Path;
11use std::path::PathBuf;
12use std::time::SystemTime;
13
14use serde::Deserialize;
15use serde::Serialize;
16
17use crate::error::Result;
18use crate::error::SnapshotError;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct SnapshotRef {
22    pub turn_id: String,
23    pub manifest_id: String,
24    /// When this entry was appended, in unix seconds.
25    ///
26    /// For display — a listing that shows only a sequence number and an
27    /// external conversation id gives a person nothing to recognise. It takes
28    /// part in no decision, so VIII.1's ban on age-based *behaviour* is
29    /// untouched: that rule is about reclamation, not about showing a clock.
30    pub at: u64,
31    /// Hash of the preceding entry, or `None` for the first.
32    ///
33    /// **Written and never read.** No threat model on the table asks for
34    /// tamper-evidence, so what a reader should do with a broken chain is not
35    /// yet decided — and deciding it wrongly is worse than deferring, since
36    /// refusing a log whose chain is broken can make a recoverable situation
37    /// unrecoverable.
38    ///
39    /// It is written now because a chain only means anything if it starts at
40    /// the first entry. Added later, every existing log stays permanently
41    /// unchained before the cut, and "this is broken" becomes impossible to
42    /// tell from "this predates the chain". Starting at entry zero keeps that
43    /// distinction available for whenever it is wanted.
44    ///
45    /// This is not the defect VII.4 names. That rule is about a property
46    /// *claimed* and not enforced; nothing here claims tamper-evidence, and
47    /// unused data is not a false promise.
48    pub prev_hash: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ThreadLog {
53    /// Format version of this record — see [`crate::manifest::Manifest`].
54    pub version: u32,
55    /// Whose log this is.
56    ///
57    /// In the record because it is no longer in the filename: the file is
58    /// named for the *hash* of the id, so that two ids differing only in case
59    /// cannot share one file on a case-insensitive filesystem. Enumerating
60    /// sessions therefore means reading the records rather than listing a
61    /// directory — see [`RefStore::thread_ids`].
62    #[serde(default)]
63    pub session: String,
64    pub entries: Vec<SnapshotRef>,
65}
66
67/// Every log a partition holds, and whether the enumeration was whole.
68///
69/// The flag exists so a sweep can tell "nothing points at this" from "I could
70/// not read everything that might". Only the first licenses a removal.
71#[derive(Debug, Default)]
72pub struct ThreadLogs {
73    pub logs: Vec<ThreadLog>,
74    /// True when at least one log could not be read or parsed.
75    pub incomplete: bool,
76}
77
78impl Default for ThreadLog {
79    fn default() -> Self {
80        Self {
81            version: crate::workspace::FORMAT_VERSION,
82            session: String::new(),
83            entries: Vec::new(),
84        }
85    }
86}
87
88impl SnapshotRef {
89    /// Build an entry that chains onto `previous`.
90    pub(crate) fn chained(turn_id: String, manifest_id: String, previous: Option<&Self>) -> Self {
91        Self {
92            turn_id,
93            manifest_id,
94            at: SystemTime::now()
95                .duration_since(SystemTime::UNIX_EPOCH)
96                .map_or(0, |d| d.as_secs()),
97            prev_hash: previous.map(Self::digest),
98        }
99    }
100
101    /// This entry's identity for the chain: the hash of its canonical JSON,
102    /// which already includes its own `prev_hash` and so covers everything
103    /// before it.
104    fn digest(entry: &Self) -> String {
105        serde_json::to_vec(entry).map_or_else(
106            |_| String::new(),
107            |bytes| crate::blob::BlobStore::hash_bytes(&bytes),
108        )
109    }
110}
111
112/// Refuse a record this build cannot read.
113///
114/// The same guard `Manifest::load` applies, shared by the four readers that
115/// needed it. Refusing is the point: a record whose version we do not know
116/// may mean anything, and the one thing it must not do is look like a record
117/// that means something else. `RestoreLog`'s `entries` and `Manifest`'s
118/// `absent` both deserialize an absent key as empty — which reads as "this
119/// session never rewound" and "this capture looked for nothing", and the
120/// second silently voids every deletion the record had licensed.
121fn check_version(kind: &'static str, id: &str, found: u32) -> Result<()> {
122    if found == crate::workspace::FORMAT_VERSION {
123        return Ok(());
124    }
125    Err(SnapshotError::UnknownRecordVersion {
126        kind,
127        id: id.to_string(),
128        found,
129        supported: crate::workspace::FORMAT_VERSION,
130    })
131}
132
133/// What a turn's index entry is suffixed with, so the directory can be read
134/// by whitelist and `with_extension("tmp")` has an extension to replace
135/// rather than eating the id's own last dot (D5, D9).
136pub(crate) const TURN_SUFFIX: &str = ".turn";
137
138pub struct RefStore {
139    root: PathBuf,
140}
141
142impl RefStore {
143    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
144        let root = root.into();
145        fs::create_dir_all(&root).map_err(|e| SnapshotError::io(&root, e))?;
146        Ok(Self { root })
147    }
148
149    /// Append a `(turn_id, manifest_id)` pair, chained onto whatever is
150    /// already there.
151    ///
152    /// Read-modify-write, which is why a session takes a lock against itself:
153    /// two concurrent appends for one session would otherwise lose an entry,
154    /// silently. A long-lived library could not hit that; a CLI invoked twice
155    /// can.
156    pub fn append(&self, thread_id: &str, turn_id: String, manifest_id: String) -> Result<()> {
157        let mut log = self.load(thread_id)?;
158        log.session = thread_id.to_string();
159        let entry = SnapshotRef::chained(turn_id, manifest_id, log.entries.last());
160        log.entries.push(entry);
161        let path = self.log_path(thread_id)?;
162        let tmp = crate::sweep::tmp_name(&path);
163        let bytes = serde_json::to_vec_pretty(&log)?;
164        fs::write(&tmp, bytes).map_err(|e| SnapshotError::io(&tmp, e))?;
165        fs::rename(&tmp, &path).map_err(|e| SnapshotError::io(&path, e))?;
166        Ok(())
167    }
168
169    /// Whether a log file exists for `thread_id`. With session-scoped
170    /// binding, log existence *is* the persisted "tracking enabled" state.
171    pub fn exists(&self, thread_id: &str) -> bool {
172        self.log_path(thread_id).is_ok_and(|p| p.exists())
173    }
174
175    /// Create an empty log for `thread_id` if none exists — the durable
176    /// marker that this session is tracking.
177    pub fn ensure(&self, thread_id: &str) -> Result<()> {
178        let path = self.log_path(thread_id)?;
179        if path.exists() {
180            return Ok(());
181        }
182        let tmp = crate::sweep::tmp_name(&path);
183        let bytes = serde_json::to_vec_pretty(&ThreadLog {
184            session: thread_id.to_string(),
185            ..ThreadLog::default()
186        })?;
187        fs::write(&tmp, bytes).map_err(|e| SnapshotError::io(&tmp, e))?;
188        fs::rename(&tmp, &path).map_err(|e| SnapshotError::io(&path, e))?;
189        Ok(())
190    }
191
192    /// Load a thread's log; a thread with no snapshots yields an empty log.
193    pub fn load(&self, thread_id: &str) -> Result<ThreadLog> {
194        let path = self.log_path(thread_id)?;
195        match fs::read(&path) {
196            Ok(bytes) => {
197                let log: ThreadLog = serde_json::from_slice(&bytes)?;
198                check_version("thread log", thread_id, log.version)?;
199                Ok(log)
200            }
201            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ThreadLog::default()),
202            Err(e) => Err(SnapshotError::io(&path, e)),
203        }
204    }
205
206    /// Drop a thread's refs (its snapshots become garbage for the next GC).
207    pub fn remove(&self, thread_id: &str) -> Result<()> {
208        let path = self.log_path(thread_id)?;
209        match fs::remove_file(&path) {
210            Ok(()) => Ok(()),
211            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
212            Err(e) => Err(SnapshotError::io(&path, e)),
213        }
214    }
215
216    /// Every thread log in this partition, and whether any could not be read.
217    ///
218    /// One damaged log used to abort the whole enumeration with `?`, which
219    /// made every sweep — and therefore every delete's reclamation — hostage
220    /// to a single corrupt file. It now reports what it could read and says
221    /// that it is incomplete, so a caller can reclaim nothing rather than
222    /// fail outright. Which it must: an unreadable log is not evidence that
223    /// its manifests are dead.
224    pub fn thread_logs(&self) -> Result<ThreadLogs> {
225        let mut out = ThreadLogs::default();
226        let entries = fs::read_dir(&self.root).map_err(|e| SnapshotError::io(&self.root, e))?;
227        for entry in entries {
228            let entry = entry.map_err(|e| SnapshotError::io(&self.root, e))?;
229            let name = entry.file_name().to_string_lossy().into_owned();
230            if !name.ends_with(".json") {
231                continue;
232            }
233            match fs::read(entry.path()).map(|b| serde_json::from_slice::<ThreadLog>(&b)) {
234                // A version this build cannot read counts as unreadable, for
235                // the same reason: it is not evidence that anything is dead.
236                Ok(Ok(log)) if log.version == crate::workspace::FORMAT_VERSION => {
237                    out.logs.push(log);
238                }
239                _ => out.incomplete = true,
240            }
241        }
242        Ok(out)
243    }
244
245    /// Where `thread_id`'s log lives, once the id is proven able to be one.
246    ///
247    /// The id becomes the filename unchanged. It used to be character-mapped
248    /// here — and mapped a second time, differently, when the turn index was
249    /// built — so two spellings could land on one file and a sweep could
250    /// disagree with a writer about which record was which (D7).
251    /// Every session with a log in this partition.
252    ///
253    /// The id *is* the filename, since ids are validated rather than mapped
254    /// (D7), so this is a listing rather than a reconstruction.
255    pub fn thread_ids(&self) -> Result<Vec<String>> {
256        let entries = fs::read_dir(&self.root).map_err(|e| SnapshotError::io(&self.root, e))?;
257        let mut out = Vec::new();
258        for entry in entries {
259            let entry = entry.map_err(|e| SnapshotError::io(&self.root, e))?;
260            if !entry.file_name().to_string_lossy().ends_with(".json") {
261                continue;
262            }
263            // The filename is a digest, so the id comes from inside. A record
264            // that will not parse is skipped rather than reported under a name
265            // derived from its hash, which would be a name nobody can use.
266            if let Ok(bytes) = fs::read(entry.path())
267                && let Ok(log) = serde_json::from_slice::<ThreadLog>(&bytes)
268                && !log.session.is_empty()
269            {
270                out.push(log.session);
271            }
272        }
273        out.sort();
274        Ok(out)
275    }
276
277    fn log_path(&self, thread_id: &str) -> Result<PathBuf> {
278        crate::id::validate_stored("session id", thread_id)?;
279        Ok(self
280            .root
281            .join(format!("{}.json", crate::id::record_name(thread_id))))
282    }
283}
284
285/// How many restores a thread remembers. Undo only needs the most recent
286/// one; the rest is history that would otherwise keep manifests alive.
287pub(crate) const MAX_RESTORE_HISTORY: usize = 20;
288
289/// A restore that has been applied, recorded so it can be undone.
290///
291/// Filed under the thread the rewind switched **to** — the one the user is
292/// sitting in when they ask to undo. Filing it under the thread that
293/// *performed* the rewind would lose it immediately, since a rewind leaves
294/// that thread behind; filing it per workspace makes concurrent sessions in
295/// one directory share a stack, where one session's undo pops another's
296/// record. The destination is the only key that is both reachable and
297/// private.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct RestoreRecord {
300    /// The state the rewind put the workspace into.
301    pub target_manifest_id: String,
302    /// What it looked like just before — where an undo returns to.
303    ///
304    /// Only the files are recorded here. The conversation an undo returns to
305    /// is the thread the rewind superseded, which is archived rather than
306    /// copied: archiving moves a rollout out of the sessions directory, and
307    /// thread lookup only searches that directory, so an archived thread
308    /// cannot be resumed or continued behind our back. Keeping the identity
309    /// rather than a copy also preserves the turn ids the snapshot store is
310    /// keyed on — a rebuilt conversation gets fresh ones, which would sever
311    /// every later rewind on that line from its snapshots.
312    pub safety_manifest_id: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct RestoreLog {
317    /// Format version of this record — see [`crate::manifest::Manifest`].
318    pub version: u32,
319    /// Whose undo stack this is; see [`ThreadLog::session`].
320    #[serde(default)]
321    pub session: String,
322    pub entries: Vec<RestoreRecord>,
323}
324
325/// Hand-written rather than derived: `#[derive(Default)]` would write
326/// `version: 0`, which is a version number no build has ever used and is
327/// worse than having no field at all — a reader would refuse a record this
328/// build itself wrote.
329impl Default for RestoreLog {
330    fn default() -> Self {
331        Self {
332            version: crate::workspace::FORMAT_VERSION,
333            session: String::new(),
334            entries: Vec::new(),
335        }
336    }
337}
338
339/// What a turn resolved to, and which turn it was.
340///
341/// A bare manifest id on disk before: the filename carried the turn id, and
342/// nothing carried a format version — the one durable record with neither.
343/// Now the filename is a digest, so both live here.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345struct TurnRecord {
346    version: u32,
347    turn: String,
348    manifest: String,
349}
350
351/// Undo records across a partition, and whether any could not be read.
352#[derive(Debug, Default)]
353pub struct RestoreLogs {
354    pub logs: Vec<RestoreLog>,
355    pub incomplete: bool,
356}
357
358/// Manifests the turn index and undo records still name.
359#[derive(Debug, Default)]
360pub struct HeldManifests {
361    pub ids: BTreeSet<String>,
362    pub incomplete: bool,
363}
364
365/// Maps turn ids to the state captured at their start, and threads to
366/// their restore history. Both are deliberately independent of any thread:
367/// a fork inherits its parent's turn ids, so a turn resolves to the same
368/// snapshot from every branch, and an undo works wherever the user happens
369/// to be.
370pub struct TurnIndex {
371    turns_root: PathBuf,
372    restores_root: PathBuf,
373}
374
375impl TurnIndex {
376    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
377        let root = root.into();
378        let turns_root = root.join("turns");
379        let restores_root = root.join("restores");
380        fs::create_dir_all(&turns_root).map_err(|e| SnapshotError::io(&turns_root, e))?;
381        fs::create_dir_all(&restores_root).map_err(|e| SnapshotError::io(&restores_root, e))?;
382        Ok(Self {
383            turns_root,
384            restores_root,
385        })
386    }
387
388    /// Record the snapshot captured at `turn_id`'s start. Later writes win:
389    /// a supplemental pre-edit attach extends that turn's capture.
390    pub fn set_turn(&self, turn_id: &str, manifest_id: &str) -> Result<()> {
391        let path = self.turn_path(turn_id)?;
392        write_atomic(
393            &path,
394            &serde_json::to_vec_pretty(&TurnRecord {
395                version: crate::workspace::FORMAT_VERSION,
396                turn: turn_id.to_string(),
397                manifest: manifest_id.to_string(),
398            })?,
399        )
400    }
401
402    pub fn manifest_for_turn(&self, turn_id: &str) -> Result<Option<String>> {
403        let path = self.turn_path(turn_id)?;
404        match fs::read(&path) {
405            Ok(bytes) => {
406                let record: TurnRecord = serde_json::from_slice(&bytes)?;
407                check_version("turn record", turn_id, record.version)?;
408                Ok(Some(record.manifest))
409            }
410            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411            Err(e) => Err(SnapshotError::io(&path, e)),
412        }
413    }
414
415    /// Manifests the turn index and the undo records still name, and whether
416    /// either could be read whole. See [`crate::refs::ThreadLogs`].
417    pub fn all_manifest_ids(&self) -> Result<HeldManifests> {
418        let mut out = HeldManifests::default();
419        let entries =
420            fs::read_dir(&self.turns_root).map_err(|e| SnapshotError::io(&self.turns_root, e))?;
421        for entry in entries {
422            let entry = entry.map_err(|e| SnapshotError::io(&self.turns_root, e))?;
423            // Whitelist, not a `.tmp` blacklist: a blacklist admits anything
424            // nobody thought to exclude, which is how a half-written file
425            // became readable as a record (D9, C4).
426            if !entry.file_name().to_string_lossy().ends_with(TURN_SUFFIX) {
427                continue;
428            }
429            match fs::read(entry.path()).map(|b| serde_json::from_slice::<TurnRecord>(&b)) {
430                Ok(Ok(record)) if record.version == crate::workspace::FORMAT_VERSION => {
431                    out.ids.insert(record.manifest);
432                }
433                _ => out.incomplete = true,
434            }
435        }
436        let logs = self.all_restore_logs()?;
437        out.incomplete |= logs.incomplete;
438        for log in logs.logs {
439            for record in log.entries {
440                out.ids.insert(record.target_manifest_id);
441                out.ids.insert(record.safety_manifest_id);
442            }
443        }
444        Ok(out)
445    }
446
447    /// Undo records filed under a session that has no log any more.
448    ///
449    /// Nothing can reach such a record to spend it, so it is not a root — but
450    /// `all_manifest_ids` reads it as one, which pins its two manifests for
451    /// good. Delete removes the pair together (D11); this finds the ones a
452    /// crash left behind.
453    pub fn orphan_restore_logs(&self, refs: &RefStore) -> Result<Vec<String>> {
454        let mut out = Vec::new();
455        let entries = fs::read_dir(&self.restores_root)
456            .map_err(|e| SnapshotError::io(&self.restores_root, e))?;
457        for entry in entries {
458            let entry = entry.map_err(|e| SnapshotError::io(&self.restores_root, e))?;
459            if !entry.file_name().to_string_lossy().ends_with(".json") {
460                continue;
461            }
462            // The id comes from inside the record now, since the filename is a
463            // digest. A record that will not parse is left alone: it is not
464            // evidence that a session is gone, and removing it would be acting
465            // on an answer we do not have.
466            let Ok(bytes) = fs::read(entry.path()) else {
467                continue;
468            };
469            let Ok(log) = serde_json::from_slice::<RestoreLog>(&bytes) else {
470                continue;
471            };
472            if log.session.is_empty() {
473                continue;
474            }
475            // Grace-gated like every other reclamation: a rewind writes the
476            // undo record under a session whose own log may be moments away.
477            if !refs.exists(&log.session) && crate::sweep::settled(&entry.path()) {
478                out.push(log.session);
479            }
480        }
481        Ok(out)
482    }
483
484    /// Remove one turn entry by its on-disk name, for the sessions a delete
485    /// just removed. Missing is fine — delete is idempotent.
486    ///
487    /// Validated like every other path builder, and for the same reason as
488    /// `blob_path`: the name is derived from a `turn_id` read back out of a
489    /// log this build deserialized but never vetted, so a forged or corrupted
490    /// entry could otherwise aim `remove_file` outside the partition (D5).
491    pub fn remove_turn_file(&self, turn_file: &str) -> Result<()> {
492        let Some(digest) = turn_file.strip_suffix(TURN_SUFFIX) else {
493            return Err(SnapshotError::InvalidId {
494                kind: "turn record",
495                id: turn_file.to_string(),
496                reason: "a turn record's name must end in `.turn`",
497            });
498        };
499        // The name is a digest, so it is checked as one. Same reason as
500        // before: this is derived from a `turn_id` read out of a log that was
501        // deserialized but never vetted, and it is handed to `remove_file`.
502        crate::id::validate_object("turn record", digest)?;
503        let path = self.turns_root.join(turn_file);
504        match fs::remove_file(&path) {
505            Ok(()) => Ok(()),
506            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
507            Err(e) => Err(SnapshotError::io(&path, e)),
508        }
509    }
510
511    pub fn push_restore(&self, thread_id: &str, record: RestoreRecord) -> Result<()> {
512        let mut log = self.restore_log(thread_id)?;
513        log.session = thread_id.to_string();
514        log.entries.push(record);
515        // Undo reaches back one step, so a long tail only pins storage.
516        if log.entries.len() > MAX_RESTORE_HISTORY {
517            let excess = log.entries.len() - MAX_RESTORE_HISTORY;
518            log.entries.drain(..excess);
519        }
520        let path = self.restore_path(thread_id)?;
521        write_atomic(&path, &serde_json::to_vec_pretty(&log)?)
522    }
523
524    /// Every undo record this thread holds, oldest first.
525    ///
526    /// Delete needs all of them: reading only the top left the manifests
527    /// named by the other nineteen out of the doomed set, so the delete that
528    /// owned them never reclaimed them.
529    pub fn restore_records(&self, thread_id: &str) -> Result<Vec<RestoreRecord>> {
530        Ok(self.restore_log(thread_id)?.entries)
531    }
532
533    /// The most recent restore this thread has to undo, if any.
534    pub fn last_restore(&self, thread_id: &str) -> Result<Option<RestoreRecord>> {
535        Ok(self.restore_log(thread_id)?.entries.pop())
536    }
537
538    /// Discard the most recent restore record, because it has just been
539    /// reversed. The log is a stack: rewinds push, undos pop, so repeated
540    /// undos walk back through repeated rewinds instead of oscillating
541    /// between the last two states.
542    pub fn pop_restore(&self, thread_id: &str) -> Result<Option<RestoreRecord>> {
543        let mut log = self.restore_log(thread_id)?;
544        let popped = log.entries.pop();
545        if popped.is_some() {
546            let path = self.restore_path(thread_id)?;
547            write_atomic(&path, &serde_json::to_vec_pretty(&log)?)?;
548        }
549        Ok(popped)
550    }
551
552    /// Drop a thread's undo records outright, for when the conversation they
553    /// belong to is deleted.
554    ///
555    /// Separate from `RefStore::remove` because the two files answer to
556    /// different lifetimes: a thread's log is what it captured, its restore
557    /// log is what was handed *to* it, and a rewind writes the second under a
558    /// thread that may have no log of its own yet. Only deleting the
559    /// conversation ends both — and leaving this behind would strand a GC
560    /// root, pinning the manifests it names for good.
561    pub fn remove_restores(&self, thread_id: &str) -> Result<()> {
562        let path = self.restore_path(thread_id)?;
563        match fs::remove_file(&path) {
564            Ok(()) => Ok(()),
565            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
566            Err(e) => Err(SnapshotError::io(&path, e)),
567        }
568    }
569
570    fn restore_log(&self, thread_id: &str) -> Result<RestoreLog> {
571        let path = self.restore_path(thread_id)?;
572        match fs::read(&path) {
573            Ok(bytes) => {
574                let log: RestoreLog = serde_json::from_slice(&bytes)?;
575                check_version("restore log", thread_id, log.version)?;
576                Ok(log)
577            }
578            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RestoreLog::default()),
579            Err(e) => Err(SnapshotError::io(&path, e)),
580        }
581    }
582
583    fn all_restore_logs(&self) -> Result<RestoreLogs> {
584        let mut out = RestoreLogs::default();
585        let entries = fs::read_dir(&self.restores_root)
586            .map_err(|e| SnapshotError::io(&self.restores_root, e))?;
587        for entry in entries {
588            let entry = entry.map_err(|e| SnapshotError::io(&self.restores_root, e))?;
589            if !entry.file_name().to_string_lossy().ends_with(".json") {
590                continue;
591            }
592            // An undo record that will not parse used to be skipped in
593            // silence, which reads to a sweep as "this holds nothing" — and
594            // its two manifests then look dead. Say so instead.
595            match fs::read(entry.path()).map(|b| serde_json::from_slice::<RestoreLog>(&b)) {
596                Ok(Ok(log)) if log.version == crate::workspace::FORMAT_VERSION => {
597                    out.logs.push(log);
598                }
599                _ => out.incomplete = true,
600            }
601        }
602        Ok(out)
603    }
604
605    /// Drop index entries for turns no thread holds any more.
606    ///
607    /// **Grace-gated, like every other reclamation.** A capture writes its
608    /// log entry before its turn file, so between those two writes a live
609    /// session's turn exists on disk while no log names it yet. Without the
610    /// window this unlinked it, and nothing ever rebuilds a turn entry —
611    /// `set_turn` runs only at capture time — so the rewind was lost
612    /// permanently, in a session nobody deleted (C12).
613    ///
614    /// `.tmp` residue is skipped here and reclaimed by
615    /// [`crate::sweep::sweep_residue`]; unlinking a half-written record
616    /// mid-rename is not this pass's job.
617    pub fn retain_turns(&self, live_turn_ids: &BTreeSet<String>) -> Result<()> {
618        let entries =
619            fs::read_dir(&self.turns_root).map_err(|e| SnapshotError::io(&self.turns_root, e))?;
620        for entry in entries {
621            let entry = entry.map_err(|e| SnapshotError::io(&self.turns_root, e))?;
622            let name = entry.file_name().to_string_lossy().into_owned();
623            if !name.ends_with(TURN_SUFFIX)
624                || live_turn_ids.contains(&name)
625                || !crate::sweep::settled(&entry.path())
626            {
627                continue;
628            }
629            fs::remove_file(entry.path()).map_err(|e| SnapshotError::io(entry.path(), e))?;
630        }
631        Ok(())
632    }
633
634    /// Where `turn_id`'s index entry lives.
635    ///
636    /// The `.turn` suffix is not decoration. Without an extension,
637    /// `write_atomic`'s `with_extension("tmp")` truncated at the last dot, so
638    /// `v1.2` and `v1.9` shared the temporary path `v1.tmp` — and dots in an
639    /// external conversation id are ordinary. Two concurrent writes could
640    /// leave one turn pointing at another turn's manifest. The suffix also
641    /// makes the whitelist D9 asks for possible here at all.
642    fn turn_path(&self, turn_id: &str) -> Result<PathBuf> {
643        crate::id::validate_stored("turn id", turn_id)?;
644        Ok(self.turns_root.join(turn_file_name(turn_id)))
645    }
646
647    fn restore_path(&self, thread_id: &str) -> Result<PathBuf> {
648        crate::id::validate_stored("session id", thread_id)?;
649        Ok(self
650            .restores_root
651            .join(format!("{}.json", crate::id::record_name(thread_id))))
652    }
653}
654
655fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
656    let tmp = crate::sweep::tmp_name(path);
657    fs::write(&tmp, bytes).map_err(|e| SnapshotError::io(&tmp, e))?;
658    fs::rename(&tmp, path).map_err(|e| SnapshotError::io(path, e))
659}
660
661/// Thread and turn ids are UUID-like; anything else is mapped to a
662/// filename-safe character set.
663/// What a turn's index entry is named, given an id already proven storable.
664pub(crate) fn turn_file_name(turn_id: &str) -> String {
665    format!("{}{TURN_SUFFIX}", crate::id::record_name(turn_id))
666}
667
668#[derive(Debug, Default, Clone, PartialEq, Eq)]
669pub struct GcStats {
670    pub manifests_kept: usize,
671    pub manifests_removed: usize,
672    pub blobs_kept: usize,
673    pub blobs_removed: usize,
674}
675
676impl GcStats {
677    /// Combine two sweeps, for a collection that walks several partitions.
678    pub(crate) fn plus(self, other: Self) -> Self {
679        Self {
680            manifests_kept: self.manifests_kept + other.manifests_kept,
681            manifests_removed: self.manifests_removed + other.manifests_removed,
682            blobs_kept: self.blobs_kept + other.blobs_kept,
683            blobs_removed: self.blobs_removed + other.blobs_removed,
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    #![allow(clippy::unwrap_used)]
691
692    use super::*;
693    use pretty_assertions::assert_eq;
694
695    #[test]
696    fn append_and_load_roundtrip() {
697        let dir = tempfile::tempdir().unwrap();
698        let refs = RefStore::open(dir.path().join("refs")).unwrap();
699
700        assert_eq!(refs.load("t1").unwrap(), ThreadLog::default());
701        refs.append("t1", "turn-1".into(), "m1".into()).unwrap();
702        refs.append("t1", "turn-2".into(), "m2".into()).unwrap();
703
704        let log = refs.load("t1").unwrap();
705        assert_eq!(log.entries.len(), 2);
706        assert_eq!(log.entries[1].manifest_id, "m2");
707    }
708
709    /// A log that will not parse is reported as such rather than aborting the
710    /// enumeration, so one damaged file cannot hold every sweep hostage.
711    #[test]
712    fn an_unreadable_log_makes_the_enumeration_incomplete_not_fatal() {
713        let dir = tempfile::tempdir().unwrap();
714        let root = dir.path().join("refs");
715        let refs = RefStore::open(&root).unwrap();
716        refs.append("good", "turn-1".into(), "m1".into()).unwrap();
717        fs::write(root.join("bad.json"), b"{ truncated").unwrap();
718
719        let logs = refs.thread_logs().unwrap();
720        assert_eq!(logs.logs.len(), 1, "the readable one still comes back");
721        assert!(logs.incomplete);
722    }
723
724    /// The chain runs unbroken from the first entry.
725    ///
726    /// That is the whole reason it is written now rather than when something
727    /// needs it: a chain begun partway leaves everything before the cut
728    /// permanently unlinked, and "this is broken" stops being distinguishable
729    /// from "this predates the chain". Nothing reads it yet, so what is under
730    /// test is that the data will be there and complete when something does.
731    #[test]
732    fn the_chain_starts_at_the_first_entry_and_never_breaks() {
733        let dir = tempfile::tempdir().unwrap();
734        let refs = RefStore::open(dir.path()).unwrap();
735
736        for i in 0..4 {
737            refs.append("t1", format!("turn-{i}"), format!("manifest-{i}"))
738                .unwrap();
739        }
740
741        let log = refs.load("t1").unwrap();
742        assert_eq!(log.entries.len(), 4);
743        assert_eq!(
744            log.entries[0].prev_hash, None,
745            "the first entry has nothing behind it"
746        );
747        for pair in log.entries.windows(2) {
748            assert_eq!(
749                pair[1].prev_hash.as_deref(),
750                Some(SnapshotRef::digest(&pair[0]).as_str()),
751                "each entry names the one before it"
752            );
753        }
754
755        // The link has to *cover* the entry it names, or it is decoration.
756        // Everything above recomputes the expectation with the very function
757        // that produced the stored value, so a `digest` returning a constant
758        // satisfies it — only the `prev_hash: None` assertion above stands on
759        // its own. Altering any field of an earlier entry, its own
760        // `prev_hash` included, must change the digest the next entry pinned.
761        let base = log.entries[0].clone();
762        let digest = SnapshotRef::digest(&base);
763        for tamper in [
764            SnapshotRef {
765                manifest_id: "swapped".into(),
766                ..base.clone()
767            },
768            SnapshotRef {
769                turn_id: "swapped".into(),
770                ..base.clone()
771            },
772            SnapshotRef {
773                at: base.at + 1,
774                ..base.clone()
775            },
776            SnapshotRef {
777                prev_hash: Some("forged".into()),
778                ..base
779            },
780        ] {
781            assert_ne!(
782                SnapshotRef::digest(&tamper),
783                digest,
784                "a field outside the digest is a field the chain does not cover: {tamper:?}"
785            );
786        }
787    }
788
789    /// A timestamp for display, and nothing decides anything by it.
790    #[test]
791    fn entries_carry_the_time_they_were_appended() {
792        let dir = tempfile::tempdir().unwrap();
793        let refs = RefStore::open(dir.path()).unwrap();
794        let before = SystemTime::now()
795            .duration_since(SystemTime::UNIX_EPOCH)
796            .unwrap()
797            .as_secs();
798
799        refs.append("t1", "turn-1".into(), "m1".into()).unwrap();
800
801        let at = refs.load("t1").unwrap().entries[0].at;
802        assert!(at >= before, "recorded at least when we started");
803    }
804
805    /// An inherited log is re-chained rather than copied, because the entries
806    /// are new entries in a new log: a chain carrying its parent's links
807    /// would describe a history this log does not have.
808    #[test]
809    fn inherited_entries_are_rechained() {
810        let dir = tempfile::tempdir().unwrap();
811        let refs = RefStore::open(dir.path()).unwrap();
812        refs.append("src", "turn-1".into(), "m1".into()).unwrap();
813        refs.append("src", "turn-2".into(), "m2".into()).unwrap();
814
815        let source = refs.load("src").unwrap();
816        for entry in &source.entries {
817            refs.append("fork", entry.turn_id.clone(), entry.manifest_id.clone())
818                .unwrap();
819        }
820
821        let fork = refs.load("fork").unwrap();
822        assert_eq!(fork.entries[0].prev_hash, None);
823        assert_eq!(
824            fork.entries[1].prev_hash.as_deref(),
825            Some(SnapshotRef::digest(&fork.entries[0]).as_str())
826        );
827    }
828
829    /// `last_restore` peeks and `pop_restore` pops.
830    ///
831    /// If the peek mutated, asking what an undo would do would consume the
832    /// answer — the conflict check reads the same record before the undo
833    /// runs, so undoing would then reverse the rewind *before* the one the
834    /// user was shown.
835    #[test]
836    fn reading_the_top_of_the_undo_stack_does_not_consume_it() {
837        let dir = tempfile::tempdir().unwrap();
838        let turns = TurnIndex::open(dir.path()).unwrap();
839
840        let record = |n: &str| RestoreRecord {
841            target_manifest_id: format!("target-{n}"),
842            safety_manifest_id: format!("safety-{n}"),
843        };
844        turns.push_restore("t1", record("a")).unwrap();
845        turns.push_restore("t1", record("b")).unwrap();
846
847        assert_eq!(turns.last_restore("t1").unwrap(), Some(record("b")));
848        assert_eq!(
849            turns.last_restore("t1").unwrap(),
850            Some(record("b")),
851            "reading twice reads the same thing"
852        );
853
854        assert_eq!(turns.pop_restore("t1").unwrap(), Some(record("b")));
855        assert_eq!(
856            turns.last_restore("t1").unwrap(),
857            Some(record("a")),
858            "a second undo walks back another rewind rather than oscillating"
859        );
860
861        assert_eq!(turns.pop_restore("t1").unwrap(), Some(record("a")));
862        assert_eq!(turns.pop_restore("t1").unwrap(), None);
863    }
864
865    /// A record from a build we do not understand is refused, not read
866    /// optimistically.
867    ///
868    /// The hazard is specific: both `ThreadLog::entries` and
869    /// `RestoreLog::entries` deserialize a missing key as empty, so a record
870    /// written by a build that spelled them differently would come back as a
871    /// session that captured nothing and never rewound — losing history
872    /// silently rather than loudly.
873    #[test]
874    fn a_record_from_an_unknown_build_is_refused() {
875        let dir = tempfile::tempdir().unwrap();
876        let root = dir.path().join("refs");
877        let refs = RefStore::open(&root).unwrap();
878        refs.append("t1", "turn-1".into(), "m1".into()).unwrap();
879
880        let name = format!("{}.json", crate::id::record_name("t1"));
881        let raw = fs::read_to_string(root.join(&name)).unwrap();
882        fs::write(
883            root.join(&name),
884            raw.replace(
885                &format!("\"version\": {}", crate::workspace::FORMAT_VERSION),
886                "\"version\": 99",
887            ),
888        )
889        .unwrap();
890
891        let err = refs.load("t1").unwrap_err();
892        assert!(
893            matches!(
894                &err,
895                SnapshotError::UnknownRecordVersion { kind, found: 99, .. } if *kind == "thread log"
896            ),
897            "{err:?}"
898        );
899
900        // And a sweep treats it as unreadable rather than as an empty log,
901        // so nothing it names is mistaken for garbage.
902        assert!(refs.thread_logs().unwrap().incomplete);
903    }
904
905    /// The undo record carries a version too, and refuses the same way.
906    #[test]
907    fn an_undo_record_from_an_unknown_build_is_refused() {
908        let dir = tempfile::tempdir().unwrap();
909        let turns = TurnIndex::open(dir.path()).unwrap();
910        turns
911            .push_restore(
912                "t1",
913                RestoreRecord {
914                    target_manifest_id: "target".into(),
915                    safety_manifest_id: "safety".into(),
916                },
917            )
918            .unwrap();
919
920        let path = dir
921            .path()
922            .join("restores")
923            .join(format!("{}.json", crate::id::record_name("t1")));
924        let raw = fs::read_to_string(&path).unwrap();
925        fs::write(
926            &path,
927            raw.replace(
928                &format!("\"version\": {}", crate::workspace::FORMAT_VERSION),
929                "\"version\": 99",
930            ),
931        )
932        .unwrap();
933
934        let err = turns.last_restore("t1").unwrap_err();
935        assert!(
936            matches!(
937                &err,
938                SnapshotError::UnknownRecordVersion { kind, found: 99, .. } if *kind == "restore log"
939            ),
940            "{err:?}"
941        );
942    }
943
944    /// A turn id read back out of a log is not a proven id.
945    ///
946    /// Every other path builder validates; this one did not, and its input is
947    /// derived from a `ThreadLog` this build deserialized but never vetted.
948    /// A forged or corrupted entry could therefore aim `remove_file` outside
949    /// the partition — the same threat `blob_path` already guarded against
950    /// (D5).
951    #[test]
952    fn a_forged_turn_record_name_cannot_escape_the_turns_directory() {
953        let dir = tempfile::tempdir().unwrap();
954        let turns = TurnIndex::open(dir.path()).unwrap();
955        let outside = dir.path().join("witness.txt");
956        fs::write(&outside, b"not ours to remove").unwrap();
957
958        for forged in [
959            "../witness.txt",
960            "../../etc/passwd.turn",
961            "..",
962            "",
963            "no-suffix",
964        ] {
965            let err = turns.remove_turn_file(forged).unwrap_err();
966            assert!(
967                matches!(err, SnapshotError::InvalidId { .. }),
968                "{forged:?} was not refused: {err:?}"
969            );
970        }
971        assert!(
972            outside.exists(),
973            "a forged name reached outside the partition"
974        );
975
976        // A real one still works, or the guard would be useless.
977        turns.set_turn("turn-1", "m1").unwrap();
978        turns.remove_turn_file(&turn_file_name("turn-1")).unwrap();
979        assert_eq!(turns.manifest_for_turn("turn-1").unwrap(), None);
980    }
981}