1use 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 pub at: u64,
31 pub prev_hash: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ThreadLog {
53 pub version: u32,
55 #[serde(default)]
63 pub session: String,
64 pub entries: Vec<SnapshotRef>,
65}
66
67#[derive(Debug, Default)]
72pub struct ThreadLogs {
73 pub logs: Vec<ThreadLog>,
74 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 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 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
112fn 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
133pub(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 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 pub fn exists(&self, thread_id: &str) -> bool {
172 self.log_path(thread_id).is_ok_and(|p| p.exists())
173 }
174
175 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 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 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 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 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 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 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
285pub(crate) const MAX_RESTORE_HISTORY: usize = 20;
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct RestoreRecord {
300 pub target_manifest_id: String,
302 pub safety_manifest_id: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct RestoreLog {
317 pub version: u32,
319 #[serde(default)]
321 pub session: String,
322 pub entries: Vec<RestoreRecord>,
323}
324
325impl 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345struct TurnRecord {
346 version: u32,
347 turn: String,
348 manifest: String,
349}
350
351#[derive(Debug, Default)]
353pub struct RestoreLogs {
354 pub logs: Vec<RestoreLog>,
355 pub incomplete: bool,
356}
357
358#[derive(Debug, Default)]
360pub struct HeldManifests {
361 pub ids: BTreeSet<String>,
362 pub incomplete: bool,
363}
364
365pub 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 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 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 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 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 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 if !refs.exists(&log.session) && crate::sweep::settled(&entry.path()) {
478 out.push(log.session);
479 }
480 }
481 Ok(out)
482 }
483
484 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 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 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 pub fn restore_records(&self, thread_id: &str) -> Result<Vec<RestoreRecord>> {
530 Ok(self.restore_log(thread_id)?.entries)
531 }
532
533 pub fn last_restore(&self, thread_id: &str) -> Result<Option<RestoreRecord>> {
535 Ok(self.restore_log(thread_id)?.entries.pop())
536 }
537
538 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 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 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 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 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
661pub(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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 assert!(refs.thread_logs().unwrap().incomplete);
903 }
904
905 #[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 #[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 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}