1use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Mutex;
38
39use serde::{Deserialize, Serialize};
40
41use crate::error::{Error, Result};
42use crate::tools::WriteObserver;
43
44pub const DEFAULT_RETAIN: usize = 50;
48
49pub type CheckpointId = String;
53
54fn now_ms() -> u128 {
55 std::time::SystemTime::now()
56 .duration_since(std::time::UNIX_EPOCH)
57 .map(|d| d.as_millis())
58 .unwrap_or(0)
59}
60
61fn mint_id() -> CheckpointId {
66 use std::hash::BuildHasher;
67 let millis = now_ms();
68 let salt = std::collections::hash_map::RandomState::new().hash_one((
69 std::process::id(),
70 millis,
71 std::time::Instant::now(),
72 ));
73 format!("{millis:020}-{:08x}", salt as u32)
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct CheckpointFileEntry {
80 pub path: String,
85 pub blob: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CheckpointManifest {
95 pub id: CheckpointId,
97 pub created_at_ms: u128,
99 pub label: String,
102 pub files: Vec<CheckpointFileEntry>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CheckpointMeta {
110 pub id: CheckpointId,
112 pub created_at_ms: u128,
114 pub label: String,
116 pub file_count: usize,
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct RestoreReport {
123 pub restored: Vec<String>,
125 pub refused: Vec<(String, String)>,
129}
130
131#[derive(Debug)]
137pub struct CheckpointStore {
138 root: PathBuf,
139}
140
141impl CheckpointStore {
142 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
147 let root = root.into();
148 std::fs::create_dir_all(root.join("objects"))?;
149 std::fs::create_dir_all(root.join("checkpoints"))?;
150 Ok(CheckpointStore { root })
151 }
152
153 fn objects_dir(&self) -> PathBuf {
154 self.root.join("objects")
155 }
156 fn checkpoints_dir(&self) -> PathBuf {
157 self.root.join("checkpoints")
158 }
159 fn manifest_path(&self, id: &str) -> PathBuf {
160 self.checkpoints_dir().join(format!("{id}.json"))
161 }
162 fn blob_path(&self, hash: &str) -> PathBuf {
163 let prefix = &hash[..hash.len().min(2)];
164 self.objects_dir().join(prefix).join(hash)
165 }
166
167 fn validate_id(id: &str) -> Result<()> {
173 let bad = id.is_empty()
174 || id.contains('/')
175 || id.contains('\\')
176 || id.contains('\0')
177 || id.split(['/', '\\']).any(|c| c == ".." || c == ".")
178 || Path::new(id).is_absolute()
179 || id.trim() != id;
180 if bad {
181 return Err(Error::Other(format!("invalid checkpoint id: `{id}`")));
182 }
183 Ok(())
184 }
185
186 fn write_blob(&self, content: &[u8]) -> Result<String> {
192 let hash = crate::reduce::content_hash(content);
193 let dest = self.blob_path(&hash);
194 if dest.exists() {
195 return Ok(hash);
196 }
197 let Some(parent) = dest.parent() else {
198 return Err(Error::Other("blob path has no parent".to_string()));
199 };
200 std::fs::create_dir_all(parent)?;
201 let tmp = parent.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
202 std::fs::write(&tmp, content)?;
203 match std::fs::rename(&tmp, &dest) {
204 Ok(()) => {}
205 Err(e) if dest.exists() => {
206 let _ = std::fs::remove_file(&tmp);
208 let _ = e;
209 }
210 Err(e) => return Err(e.into()),
211 }
212 Ok(hash)
213 }
214
215 fn read_blob(&self, hash: &str) -> Result<Vec<u8>> {
216 std::fs::read(self.blob_path(hash)).map_err(Into::into)
217 }
218
219 fn write_manifest(&self, m: &CheckpointManifest) -> Result<()> {
220 Self::validate_id(&m.id)?;
221 let dest = self.manifest_path(&m.id);
222 let json = serde_json::to_vec_pretty(m).map_err(|e| Error::Other(e.to_string()))?;
223 let dir = self.checkpoints_dir();
224 std::fs::create_dir_all(&dir)?;
225 let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
226 std::fs::write(&tmp, &json)?;
227 std::fs::rename(&tmp, &dest)?;
228 Ok(())
229 }
230
231 pub fn create_checkpoint(&self, label: &str) -> Result<CheckpointId> {
234 let id = mint_id();
235 let manifest = CheckpointManifest {
236 id: id.clone(),
237 created_at_ms: now_ms(),
238 label: label.to_string(),
239 files: Vec::new(),
240 };
241 self.write_manifest(&manifest)?;
242 Ok(id)
243 }
244
245 pub fn record_pre_image(&self, id: &str, rel: &str, content: Option<Vec<u8>>) -> Result<()> {
250 let mut manifest = self.manifest(id)?;
251 if manifest.files.iter().any(|f| f.path == rel) {
252 return Ok(());
253 }
254 let blob = match content {
255 Some(bytes) => Some(self.write_blob(&bytes)?),
256 None => None,
257 };
258 manifest.files.push(CheckpointFileEntry {
259 path: rel.to_string(),
260 blob,
261 });
262 self.write_manifest(&manifest)
263 }
264
265 pub fn manifest(&self, id: &str) -> Result<CheckpointManifest> {
267 Self::validate_id(id)?;
268 let text = std::fs::read_to_string(self.manifest_path(id))
269 .map_err(|e| Error::Other(format!("checkpoint `{id}` not found: {e}")))?;
270 serde_json::from_str(&text)
271 .map_err(|e| Error::Other(format!("checkpoint `{id}` manifest is corrupt: {e}")))
272 }
273
274 pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
279 let dir = self.checkpoints_dir();
280 let mut metas = Vec::new();
281 if !dir.exists() {
282 return Ok(metas);
283 }
284 for entry in std::fs::read_dir(&dir)? {
285 let entry = entry?;
286 let path = entry.path();
287 if path.extension().and_then(|e| e.to_str()) != Some("json") {
288 continue;
289 }
290 let Ok(text) = std::fs::read_to_string(&path) else {
291 continue;
292 };
293 if let Ok(m) = serde_json::from_str::<CheckpointManifest>(&text) {
294 metas.push(CheckpointMeta {
295 id: m.id,
296 created_at_ms: m.created_at_ms,
297 label: m.label,
298 file_count: m.files.len(),
299 });
300 }
301 }
302 metas.sort_by(|a, b| b.id.cmp(&a.id));
303 Ok(metas)
304 }
305
306 pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
310 Ok(self
311 .manifest(id)?
312 .files
313 .into_iter()
314 .map(|f| f.path)
315 .collect())
316 }
317
318 pub fn turn_patch(&self, id: &str, project_root: &Path) -> Result<String> {
336 let mut entries = self.manifest(id)?.files;
337 entries.sort_by(|a, b| a.path.cmp(&b.path));
338 let mut out = String::new();
339 for entry in entries {
340 let before: Option<Vec<u8>> = match &entry.blob {
341 Some(blob) => Some(self.read_blob(blob)?),
342 None => None,
343 };
344 let after = std::fs::read(project_root.join(&entry.path)).ok();
345 if before == after {
346 continue;
347 }
348 let (Some(before_text), Some(after_text)) = (
349 decode_side(before.as_deref()),
350 decode_side(after.as_deref()),
351 ) else {
352 out.push_str(&format!(
353 "Binary files a/{0} and b/{0} differ
354",
355 entry.path
356 ));
357 continue;
358 };
359 let patch = diffy::create_patch(&before_text, &after_text);
360 out.push_str(&format!(
361 "--- a/{0}
362+++ b/{0}
363",
364 entry.path
365 ));
366 for line in patch.to_string().lines().skip(2) {
370 out.push_str(line);
371 out.push('\n');
372 }
373 }
374 Ok(out)
375 }
376
377 pub fn restore(
393 &self,
394 id: &str,
395 project_root: &Path,
396 protected_globs: &[String],
397 ) -> Result<RestoreReport> {
398 let manifest = self.manifest(id)?;
399 let mut report = RestoreReport::default();
400 for entry in &manifest.files {
401 if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
410 report.refused.push((entry.path.clone(), reason));
411 continue;
412 }
413 let target = project_root.join(&entry.path);
414 let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
423 report.refused.push((
424 entry.path.clone(),
425 "refused: escapes the project root".to_string(),
426 ));
427 continue;
428 };
429 if is_protected(&normalized_rel, protected_globs) {
430 report
431 .refused
432 .push((entry.path.clone(), "refused: protected path".to_string()));
433 continue;
434 }
435 if !contained(project_root, &target) {
439 report.refused.push((
440 entry.path.clone(),
441 "refused: escapes the project root".to_string(),
442 ));
443 continue;
444 }
445 if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
462 if is_protected(&resolved_rel, protected_globs) {
463 report
464 .refused
465 .push((entry.path.clone(), "refused: protected path".to_string()));
466 continue;
467 }
468 }
469 match &entry.blob {
470 Some(hash) => {
471 let bytes = match self.read_blob(hash) {
472 Ok(b) => b,
473 Err(e) => {
474 report
475 .refused
476 .push((entry.path.clone(), format!("blob unreadable: {e}")));
477 continue;
478 }
479 };
480 if let Some(parent) = target.parent() {
481 let _ = std::fs::create_dir_all(parent);
482 }
483 if let Err(e) = std::fs::write(&target, &bytes) {
484 report
485 .refused
486 .push((entry.path.clone(), format!("write failed: {e}")));
487 continue;
488 }
489 }
490 None if target.exists() => {
491 if let Err(e) = std::fs::remove_file(&target) {
492 report
493 .refused
494 .push((entry.path.clone(), format!("delete failed: {e}")));
495 continue;
496 }
497 }
498 None => {}
499 }
500 report.restored.push(entry.path.clone());
501 }
502 Ok(report)
503 }
504
505 pub fn prune(&self, keep: usize) -> Result<usize> {
509 let mut metas = self.list()?; if metas.len() <= keep {
511 return Ok(0);
512 }
513 let stale = metas.split_off(keep);
514 let removed = stale.len();
515 for m in stale {
516 let _ = std::fs::remove_file(self.manifest_path(&m.id));
517 }
518 self.gc_unreferenced_blobs()?;
519 Ok(removed)
520 }
521
522 fn gc_unreferenced_blobs(&self) -> Result<()> {
527 let mut referenced: HashSet<String> = HashSet::new();
528 for meta in self.list()? {
529 if let Ok(m) = self.manifest(&meta.id) {
530 for f in m.files {
531 if let Some(b) = f.blob {
532 referenced.insert(b);
533 }
534 }
535 }
536 }
537 let objects = self.objects_dir();
538 if !objects.exists() {
539 return Ok(());
540 }
541 for entry in std::fs::read_dir(&objects)? {
542 let entry = entry?;
543 if !entry.file_type()?.is_dir() {
544 continue;
545 }
546 for inner in std::fs::read_dir(entry.path())? {
547 let inner = inner?;
548 let name = inner.file_name();
549 let Some(name) = name.to_str() else {
550 continue;
551 };
552 if name.starts_with(".tmp-") {
553 continue; }
555 if !referenced.contains(name) {
556 let _ = std::fs::remove_file(inner.path());
557 }
558 }
559 }
560 Ok(())
561 }
562}
563
564fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
590 crate::safe_path::is_protected(rel, extra_globs)
591}
592
593fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
605 crate::safe_path::reject_unsafe_rel_path(rel)
606}
607
608fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
618 crate::safe_path::normalized_project_rel(root, path)
619}
620
621fn contained(root: &Path, path: &Path) -> bool {
626 crate::safe_path::contained(root, path)
627}
628
629fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
645 crate::safe_path::resolved_project_rel(root, path)
646}
647
648#[derive(Debug, Default)]
649struct ObserverState {
650 current: Option<CheckpointId>,
652 captured: HashSet<String>,
656}
657
658#[derive(Debug)]
664pub struct CheckpointObserver {
665 store: CheckpointStore,
666 project_root: PathBuf,
667 retain: usize,
668 protected: Vec<String>,
669 restore_enabled: bool,
672 state: Mutex<ObserverState>,
673 disabled: AtomicBool,
678}
679
680impl CheckpointObserver {
681 pub fn new(
686 store: CheckpointStore,
687 project_root: PathBuf,
688 retain: usize,
689 protected: Vec<String>,
690 ) -> Self {
691 Self::with_restore(store, project_root, retain, protected, true)
692 }
693
694 pub fn with_restore(
697 store: CheckpointStore,
698 project_root: PathBuf,
699 retain: usize,
700 protected: Vec<String>,
701 restore_enabled: bool,
702 ) -> Self {
703 CheckpointObserver {
704 store,
705 project_root,
706 retain: retain.max(1),
707 protected,
708 restore_enabled,
709 state: Mutex::new(ObserverState::default()),
710 disabled: AtomicBool::new(false),
711 }
712 }
713
714 pub fn store(&self) -> &CheckpointStore {
717 &self.store
718 }
719
720 pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
722 self.store.list()
723 }
724
725 pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
727 self.store.turn_diff(id)
728 }
729
730 pub fn turn_patch(&self, id: &str) -> Result<String> {
733 self.store.turn_patch(id, &self.project_root)
734 }
735
736 pub fn restore_enabled(&self) -> bool {
743 self.restore_enabled
744 }
745
746 pub fn restore(&self, id: &str) -> Result<RestoreReport> {
751 if !self.restore_enabled {
752 return Err(Error::Other(
753 "checkpoint restore is off for this harness (`[capabilities.checkpoint] restore = false`) — this preset tracks each turn's diff but has no code-restore surface"
754 .to_string(),
755 ));
756 }
757 self.store.restore(id, &self.project_root, &self.protected)
758 }
759
760 pub fn is_disabled(&self) -> bool {
762 self.disabled.load(Ordering::Relaxed)
763 }
764
765 pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
775 if self.disabled.load(Ordering::Relaxed) {
776 return None;
777 }
778 let short: String = label.chars().take(120).collect();
779 match self.store.create_checkpoint(&short) {
780 Ok(id) => {
781 if let Ok(mut st) = self.state.lock() {
782 st.current = Some(id.clone());
783 st.captured.clear();
784 }
785 if let Err(e) = self.store.prune(self.retain) {
786 eprintln!("warning: checkpoint: prune failed: {e}");
787 }
788 Some(id)
789 }
790 Err(e) => {
791 eprintln!(
792 "warning: checkpoint disabled for the rest of this session — \
793 failed to open a new checkpoint: {e}"
794 );
795 self.disabled.store(true, Ordering::Relaxed);
796 None
797 }
798 }
799 }
800
801 pub fn current(&self) -> Option<CheckpointId> {
803 self.state.lock().ok().and_then(|s| s.current.clone())
804 }
805}
806
807#[async_trait::async_trait]
808impl WriteObserver for CheckpointObserver {
809 async fn before_write(&self, path: &Path) {
810 if self.disabled.load(Ordering::Relaxed) {
811 return;
812 }
813 if !contained(&self.project_root, path) {
814 return;
817 }
818 let Some(normalized) = crate::tools::normalize(path) else {
819 return;
820 };
821 let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
822 return;
823 };
824 let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
825 return;
826 };
827 let rel = rel_path.to_string_lossy().replace('\\', "/");
828 if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
829 return;
830 }
831
832 let mut st = match self.state.lock() {
839 Ok(g) => g,
840 Err(poisoned) => poisoned.into_inner(),
841 };
842 if st.current.is_none() {
843 drop(st);
849 self.begin_turn("untracked");
850 st = match self.state.lock() {
851 Ok(g) => g,
852 Err(poisoned) => poisoned.into_inner(),
853 };
854 }
855 if st.captured.contains(&rel) {
856 return;
857 }
858 let Some(id) = st.current.clone() else {
859 return; };
861 let content = std::fs::read(path).ok(); match self.store.record_pre_image(&id, &rel, content) {
863 Ok(()) => {
864 st.captured.insert(rel);
865 }
866 Err(e) => {
867 eprintln!(
868 "warning: checkpoint disabled for the rest of this session — \
869 failed to record a snapshot: {e}"
870 );
871 self.disabled.store(true, Ordering::Relaxed);
872 }
873 }
874 }
875
876 async fn after_write(&self, _path: &Path) -> Option<String> {
877 None
885 }
886}
887
888pub(crate) fn project_tag(cwd: &Path) -> String {
901 use std::hash::{Hash, Hasher};
902 let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
903 let mut h = std::collections::hash_map::DefaultHasher::new();
904 canon.hash(&mut h);
905 format!("{:016x}", h.finish())
906}
907
908fn default_shadow_root(cwd: &Path) -> PathBuf {
913 crate::agent::global_instructions_dir()
914 .join("checkpoints")
915 .join(project_tag(cwd))
916}
917
918fn decode_side(bytes: Option<&[u8]>) -> Option<String> {
935 match bytes {
936 None => Some(String::new()),
937 Some(b) => String::from_utf8(b.to_vec()).ok(),
938 }
939}
940
941pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
942 if !config.checkpoint_enabled {
943 return None;
944 }
945 let root = config
946 .checkpoint_dir
947 .clone()
948 .unwrap_or_else(|| default_shadow_root(&config.cwd));
949 match CheckpointStore::open(&root) {
950 Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::with_restore(
951 store,
952 config.cwd.clone(),
953 config.checkpoint_retain,
954 config.permissions_protected_paths.clone(),
955 config.checkpoint_restore,
956 ))),
957 Err(e) => {
958 eprintln!(
959 "warning: [capabilities.checkpoint] is enabled but the shadow store at \
960 {} could not be opened — checkpoint is disabled for this session: {e}",
961 root.display()
962 );
963 None
964 }
965 }
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 fn tmp(tag: &str) -> PathBuf {
973 let dir = std::env::temp_dir().join(format!(
974 "supercode-checkpoint-test-{tag}-{}-{}",
975 std::process::id(),
976 mint_id()
977 ));
978 std::fs::create_dir_all(&dir).unwrap();
979 dir
980 }
981
982 #[test]
983 fn open_creates_objects_and_checkpoints_dirs() {
984 let root = tmp("open");
985 let store = CheckpointStore::open(&root).unwrap();
986 assert!(root.join("objects").is_dir());
987 assert!(root.join("checkpoints").is_dir());
988 drop(store);
989 std::fs::remove_dir_all(&root).ok();
990 }
991
992 #[test]
993 fn create_list_and_manifest_round_trip() {
994 let root = tmp("list");
995 let store = CheckpointStore::open(&root).unwrap();
996 let id1 = store.create_checkpoint("first turn").unwrap();
997 std::thread::sleep(std::time::Duration::from_millis(2));
998 let id2 = store.create_checkpoint("second turn").unwrap();
999 let metas = store.list().unwrap();
1000 assert_eq!(metas.len(), 2);
1001 assert_eq!(metas[0].id, id2);
1003 assert_eq!(metas[1].id, id1);
1004 assert_eq!(metas[0].label, "second turn");
1005 let m = store.manifest(&id1).unwrap();
1006 assert_eq!(m.id, id1);
1007 assert!(m.files.is_empty());
1008 std::fs::remove_dir_all(&root).ok();
1009 }
1010
1011 #[test]
1012 fn record_pre_image_is_idempotent_keeping_the_earliest() {
1013 let root = tmp("idempotent");
1014 let store = CheckpointStore::open(&root).unwrap();
1015 let id = store.create_checkpoint("t").unwrap();
1016 store
1017 .record_pre_image(&id, "a.txt", Some(b"first".to_vec()))
1018 .unwrap();
1019 store
1022 .record_pre_image(&id, "a.txt", Some(b"second".to_vec()))
1023 .unwrap();
1024 let m = store.manifest(&id).unwrap();
1025 assert_eq!(m.files.len(), 1);
1026 let bytes = store
1027 .read_blob(m.files[0].blob.as_deref().unwrap())
1028 .unwrap();
1029 assert_eq!(bytes, b"first");
1030 std::fs::remove_dir_all(&root).ok();
1031 }
1032
1033 #[test]
1034 fn blob_dedup_two_identical_contents_share_one_object() {
1035 let root = tmp("dedup");
1036 let store = CheckpointStore::open(&root).unwrap();
1037 let id = store.create_checkpoint("t").unwrap();
1038 store
1039 .record_pre_image(&id, "a.txt", Some(b"same".to_vec()))
1040 .unwrap();
1041 store
1042 .record_pre_image(&id, "b.txt", Some(b"same".to_vec()))
1043 .unwrap();
1044 let m = store.manifest(&id).unwrap();
1045 assert_eq!(m.files[0].blob, m.files[1].blob);
1046 let mut count = 0;
1048 for entry in walkdir(&root.join("objects")) {
1049 if entry.is_file() && !entry.to_string_lossy().contains(".tmp-") {
1050 count += 1;
1051 }
1052 }
1053 assert_eq!(count, 1);
1054 std::fs::remove_dir_all(&root).ok();
1055 }
1056
1057 fn walkdir(dir: &Path) -> Vec<PathBuf> {
1058 let mut out = Vec::new();
1059 let Ok(rd) = std::fs::read_dir(dir) else {
1060 return out;
1061 };
1062 for entry in rd.flatten() {
1063 let p = entry.path();
1064 if p.is_dir() {
1065 out.extend(walkdir(&p));
1066 } else {
1067 out.push(p);
1068 }
1069 }
1070 out
1071 }
1072
1073 #[test]
1074 fn restore_rewrites_modified_and_deletes_created_files() {
1075 let root = tmp("restore");
1076 let project = tmp("restore-project");
1077 std::fs::write(project.join("existing.txt"), "modified").unwrap();
1078 let store = CheckpointStore::open(&root).unwrap();
1079 let id = store.create_checkpoint("t").unwrap();
1080 store
1082 .record_pre_image(&id, "existing.txt", Some(b"original".to_vec()))
1083 .unwrap();
1084 store.record_pre_image(&id, "new.txt", None).unwrap();
1086 std::fs::write(project.join("new.txt"), "brand new").unwrap();
1087
1088 let report = store.restore(&id, &project, &[]).unwrap();
1089 assert!(report.refused.is_empty(), "{:?}", report.refused);
1090 assert_eq!(report.restored.len(), 2);
1091 assert_eq!(
1092 std::fs::read_to_string(project.join("existing.txt")).unwrap(),
1093 "original"
1094 );
1095 assert!(!project.join("new.txt").exists());
1096 std::fs::remove_dir_all(&root).ok();
1097 std::fs::remove_dir_all(&project).ok();
1098 }
1099
1100 #[test]
1101 fn restore_refuses_a_manifest_entry_that_traverses_outside_the_project_root() {
1102 let root = tmp("hostile");
1107 let project = tmp("hostile-project");
1108 std::fs::create_dir_all(&project).unwrap();
1109 let store = CheckpointStore::open(&root).unwrap();
1110 let id = store.create_checkpoint("t").unwrap();
1111 let mut m = store.manifest(&id).unwrap();
1114 m.files.push(CheckpointFileEntry {
1115 path: "../../../../../../etc/passwd-supercode-test".to_string(),
1116 blob: None,
1117 });
1118 store.write_manifest(&m).unwrap();
1119
1120 let victim = project
1121 .parent()
1122 .unwrap()
1123 .parent()
1124 .unwrap()
1125 .join("etc/passwd-supercode-test");
1126 assert!(
1127 !victim.exists(),
1128 "test precondition: victim path must not already exist"
1129 );
1130
1131 let report = store.restore(&id, &project, &[]).unwrap();
1132 assert_eq!(report.restored.len(), 0);
1133 assert_eq!(report.refused.len(), 1);
1134 assert!(report.refused[0].1.contains("escapes"));
1135 assert!(
1136 !victim.exists(),
1137 "restore must never have written outside the project root"
1138 );
1139 std::fs::remove_dir_all(&root).ok();
1140 std::fs::remove_dir_all(&project).ok();
1141 }
1142
1143 #[test]
1144 fn restore_refuses_dot_git_unconditionally() {
1145 let root = tmp("gitfloor");
1146 let project = tmp("gitfloor-project");
1147 std::fs::create_dir_all(project.join(".git")).unwrap();
1148 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1149 let store = CheckpointStore::open(&root).unwrap();
1150 let id = store.create_checkpoint("t").unwrap();
1151 let mut m = store.manifest(&id).unwrap();
1152 m.files.push(CheckpointFileEntry {
1153 path: ".git/config".to_string(),
1154 blob: None,
1155 });
1156 store.write_manifest(&m).unwrap();
1157
1158 let report = store.restore(&id, &project, &[]).unwrap();
1159 assert_eq!(report.restored.len(), 0);
1160 assert_eq!(report.refused.len(), 1);
1161 assert!(report.refused[0].1.contains("protected"));
1162 assert_eq!(
1163 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1164 "real git config",
1165 "the real .git must be untouched"
1166 );
1167 std::fs::remove_dir_all(&root).ok();
1168 std::fs::remove_dir_all(&project).ok();
1169 }
1170
1171 #[test]
1172 fn restore_honors_extra_protected_globs() {
1173 let root = tmp("protectedglob");
1174 let project = tmp("protectedglob-project");
1175 std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1176 let store = CheckpointStore::open(&root).unwrap();
1177 let id = store.create_checkpoint("t").unwrap();
1178 let mut m = store.manifest(&id).unwrap();
1179 m.files.push(CheckpointFileEntry {
1180 path: ".env".to_string(),
1181 blob: Some(store.write_blob(b"OLD=1").unwrap()),
1182 });
1183 store.write_manifest(&m).unwrap();
1184
1185 let report = store
1186 .restore(&id, &project, &[".env*".to_string()])
1187 .unwrap();
1188 assert_eq!(report.restored.len(), 0);
1189 assert_eq!(report.refused.len(), 1);
1190 assert_eq!(
1191 std::fs::read_to_string(project.join(".env")).unwrap(),
1192 "SECRET=1"
1193 );
1194 std::fs::remove_dir_all(&root).ok();
1195 std::fs::remove_dir_all(&project).ok();
1196 }
1197
1198 #[test]
1199 fn restore_refuses_traversal_into_dot_git_real_git_config_stays_untouched() {
1200 let root = tmp("traversal-git");
1210 let project = tmp("traversal-git-project");
1211 std::fs::create_dir_all(project.join(".git")).unwrap();
1212 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1213 let store = CheckpointStore::open(&root).unwrap();
1214 let id = store.create_checkpoint("t").unwrap();
1215 let mut m = store.manifest(&id).unwrap();
1216 m.files.push(CheckpointFileEntry {
1217 path: "x/../.git/config".to_string(),
1218 blob: Some(store.write_blob(b"PWNED-by-traversal").unwrap()),
1219 });
1220 store.write_manifest(&m).unwrap();
1221
1222 let report = store.restore(&id, &project, &[]).unwrap();
1223 assert_eq!(report.restored.len(), 0, "must not restore into .git");
1224 assert_eq!(report.refused.len(), 1);
1225 assert!(
1226 report.refused[0].1.contains("escapes"),
1227 "unexpected refusal reason: {}",
1228 report.refused[0].1
1229 );
1230 assert_eq!(
1231 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1232 "real git config",
1233 "the real .git/config must be untouched by the traversal entry"
1234 );
1235 std::fs::remove_dir_all(&root).ok();
1236 std::fs::remove_dir_all(&project).ok();
1237 }
1238
1239 #[test]
1240 fn restore_refuses_traversal_bypass_of_protected_globs_dot_env_stays_untouched() {
1241 let root = tmp("traversal-env");
1246 let project = tmp("traversal-env-project");
1247 std::fs::create_dir_all(&project).unwrap();
1248 std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1249 let store = CheckpointStore::open(&root).unwrap();
1250 let id = store.create_checkpoint("t").unwrap();
1251 let mut m = store.manifest(&id).unwrap();
1252 m.files.push(CheckpointFileEntry {
1253 path: "x/../.env".to_string(),
1254 blob: Some(store.write_blob(b"PWNED=1").unwrap()),
1255 });
1256 store.write_manifest(&m).unwrap();
1257
1258 let report = store
1259 .restore(&id, &project, &[".env*".to_string()])
1260 .unwrap();
1261 assert_eq!(report.restored.len(), 0);
1262 assert_eq!(report.refused.len(), 1);
1263 assert_eq!(
1264 std::fs::read_to_string(project.join(".env")).unwrap(),
1265 "SECRET=1",
1266 "the real .env must be untouched by the traversal entry"
1267 );
1268 std::fs::remove_dir_all(&root).ok();
1269 std::fs::remove_dir_all(&project).ok();
1270 }
1271
1272 #[test]
1273 fn restore_refuses_an_absolute_manifest_path() {
1274 let root = tmp("absolute");
1285 let project = tmp("absolute-project");
1286 std::fs::create_dir_all(project.join(".git")).unwrap();
1287 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1288 let store = CheckpointStore::open(&root).unwrap();
1289 let id = store.create_checkpoint("t").unwrap();
1290 let absolute_git_config = project.join(".git").join("config");
1291 let mut m = store.manifest(&id).unwrap();
1292 m.files.push(CheckpointFileEntry {
1293 path: absolute_git_config.to_string_lossy().to_string(),
1294 blob: Some(store.write_blob(b"PWNED-by-absolute-path").unwrap()),
1295 });
1296 store.write_manifest(&m).unwrap();
1297
1298 let report = store.restore(&id, &project, &[]).unwrap();
1299 assert_eq!(report.restored.len(), 0);
1300 assert_eq!(report.refused.len(), 1);
1301 assert!(
1302 report.refused[0].1.contains("escapes"),
1303 "unexpected refusal reason: {}",
1304 report.refused[0].1
1305 );
1306 assert_eq!(
1307 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1308 "real git config"
1309 );
1310 std::fs::remove_dir_all(&root).ok();
1311 std::fs::remove_dir_all(&project).ok();
1312 }
1313
1314 #[test]
1315 fn restore_still_restores_a_clean_relative_nested_entry() {
1316 let root = tmp("cleanroundtrip");
1321 let project = tmp("cleanroundtrip-project");
1322 std::fs::create_dir_all(project.join("src")).unwrap();
1323 std::fs::write(project.join("src").join("main.rs"), "fn main() {}").unwrap();
1324 let store = CheckpointStore::open(&root).unwrap();
1325 let id = store.create_checkpoint("t").unwrap();
1326 store
1327 .record_pre_image(&id, "src/main.rs", Some(b"fn old() {}".to_vec()))
1328 .unwrap();
1329
1330 let report = store.restore(&id, &project, &[]).unwrap();
1331 assert!(report.refused.is_empty(), "{:?}", report.refused);
1332 assert_eq!(report.restored, vec!["src/main.rs".to_string()]);
1333 assert_eq!(
1334 std::fs::read_to_string(project.join("src").join("main.rs")).unwrap(),
1335 "fn old() {}"
1336 );
1337 std::fs::remove_dir_all(&root).ok();
1338 std::fs::remove_dir_all(&project).ok();
1339 }
1340
1341 #[test]
1342 #[cfg(unix)]
1343 fn restore_refuses_symlink_traversal_into_dot_git() {
1344 let root = tmp("symlink-traversal");
1359 let project = tmp("symlink-traversal-project");
1360 std::fs::create_dir_all(project.join(".git")).unwrap();
1361 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1362 std::os::unix::fs::symlink(project.join(".git"), project.join("foo")).unwrap();
1363
1364 let store = CheckpointStore::open(&root).unwrap();
1365 let id = store.create_checkpoint("t").unwrap();
1366 let mut m = store.manifest(&id).unwrap();
1367 m.files.push(CheckpointFileEntry {
1368 path: "foo/config".to_string(),
1369 blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK").unwrap()),
1370 });
1371 store.write_manifest(&m).unwrap();
1372
1373 let report = store.restore(&id, &project, &[]).unwrap();
1374 assert_eq!(
1375 report.restored.len(),
1376 0,
1377 "must not restore through the symlink into .git"
1378 );
1379 assert_eq!(report.refused.len(), 1);
1380 assert!(
1381 report.refused[0].1.contains("protected"),
1382 "unexpected refusal reason: {}",
1383 report.refused[0].1
1384 );
1385 assert_eq!(
1386 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1387 "real git config",
1388 "the real .git/config must be byte-identical — untouched by the symlink entry"
1389 );
1390 std::fs::remove_dir_all(&root).ok();
1391 std::fs::remove_dir_all(&project).ok();
1392 }
1393
1394 #[test]
1395 #[cfg(unix)]
1396 fn restore_refuses_symlink_traversal_into_a_protected_glob() {
1397 let root = tmp("symlink-glob");
1403 let project = tmp("symlink-glob-project");
1404 std::fs::create_dir_all(project.join("real_env")).unwrap();
1405 std::fs::write(project.join("real_env").join("creds"), "real secret").unwrap();
1406 std::os::unix::fs::symlink(project.join("real_env"), project.join("secrets")).unwrap();
1407
1408 let store = CheckpointStore::open(&root).unwrap();
1409 let id = store.create_checkpoint("t").unwrap();
1410 let mut m = store.manifest(&id).unwrap();
1411 m.files.push(CheckpointFileEntry {
1412 path: "secrets/creds".to_string(),
1413 blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK-GLOB").unwrap()),
1414 });
1415 store.write_manifest(&m).unwrap();
1416
1417 let report = store
1418 .restore(&id, &project, &["real_env/**".to_string()])
1419 .unwrap();
1420 assert_eq!(report.restored.len(), 0);
1421 assert_eq!(report.refused.len(), 1);
1422 assert!(
1423 report.refused[0].1.contains("protected"),
1424 "unexpected refusal reason: {}",
1425 report.refused[0].1
1426 );
1427 assert_eq!(
1428 std::fs::read_to_string(project.join("real_env").join("creds")).unwrap(),
1429 "real secret",
1430 "the real protected file must be untouched by the symlink entry"
1431 );
1432 std::fs::remove_dir_all(&root).ok();
1433 std::fs::remove_dir_all(&project).ok();
1434 }
1435
1436 #[test]
1437 fn prune_keeps_only_the_newest_and_gcs_unreferenced_blobs() {
1438 let root = tmp("prune");
1439 let store = CheckpointStore::open(&root).unwrap();
1440 for i in 0..5 {
1441 let id = store.create_checkpoint(&format!("turn {i}")).unwrap();
1442 store
1443 .record_pre_image(&id, "f.txt", Some(format!("content-{i}").into_bytes()))
1444 .unwrap();
1445 std::thread::sleep(std::time::Duration::from_millis(2));
1446 }
1447 assert_eq!(store.list().unwrap().len(), 5);
1448 let removed = store.prune(2).unwrap();
1449 assert_eq!(removed, 3);
1450 let remaining = store.list().unwrap();
1451 assert_eq!(remaining.len(), 2);
1452 assert_eq!(remaining[0].label, "turn 4");
1454 assert_eq!(remaining[1].label, "turn 3");
1455 for meta in &remaining {
1457 let m = store.manifest(&meta.id).unwrap();
1458 for f in &m.files {
1459 if let Some(hash) = &f.blob {
1460 store.read_blob(hash).unwrap();
1461 }
1462 }
1463 }
1464 std::fs::remove_dir_all(&root).ok();
1465 }
1466
1467 #[test]
1468 fn turn_diff_lists_exactly_the_captured_files() {
1469 let root = tmp("diff");
1470 let store = CheckpointStore::open(&root).unwrap();
1471 let id = store.create_checkpoint("t").unwrap();
1472 store.record_pre_image(&id, "a.rs", None).unwrap();
1473 store
1474 .record_pre_image(&id, "b.rs", Some(b"x".to_vec()))
1475 .unwrap();
1476 let mut diff = store.turn_diff(&id).unwrap();
1477 diff.sort();
1478 assert_eq!(diff, vec!["a.rs".to_string(), "b.rs".to_string()]);
1479 std::fs::remove_dir_all(&root).ok();
1480 }
1481
1482 #[test]
1483 fn manifest_rejects_a_path_traversing_id() {
1484 let root = tmp("badid");
1485 let store = CheckpointStore::open(&root).unwrap();
1486 assert!(store.manifest("../../etc/passwd").is_err());
1487 assert!(store.restore("../../etc/passwd", &root, &[]).is_err());
1488 std::fs::remove_dir_all(&root).ok();
1489 }
1490
1491 #[tokio::test]
1492 async fn observer_before_write_ignores_paths_outside_the_project_root() {
1493 let root = tmp("obs-outside");
1494 let project = tmp("obs-outside-project");
1495 let outside = tmp("obs-outside-elsewhere");
1496 std::fs::write(outside.join("victim.txt"), "do not touch").unwrap();
1497 let store = CheckpointStore::open(&root).unwrap();
1498 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1499 observer.begin_turn("t");
1500 observer.before_write(&outside.join("victim.txt")).await;
1501 let id = observer.current().unwrap();
1503 let diff = observer.store().turn_diff(&id).unwrap();
1504 assert!(
1505 diff.is_empty(),
1506 "must not capture writes outside project_root"
1507 );
1508 std::fs::remove_dir_all(&root).ok();
1509 std::fs::remove_dir_all(&project).ok();
1510 std::fs::remove_dir_all(&outside).ok();
1511 }
1512
1513 #[tokio::test]
1514 async fn observer_captures_only_the_first_write_to_a_path_in_a_turn() {
1515 let root = tmp("obs-firstwrite");
1516 let project = tmp("obs-firstwrite-project");
1517 std::fs::write(project.join("f.txt"), "v1").unwrap();
1518 let store = CheckpointStore::open(&root).unwrap();
1519 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1520 observer.begin_turn("t");
1521 observer.before_write(&project.join("f.txt")).await;
1522 std::fs::write(project.join("f.txt"), "v2").unwrap();
1523 observer.before_write(&project.join("f.txt")).await; let id = observer.current().unwrap();
1525 let m = observer.store().manifest(&id).unwrap();
1526 assert_eq!(m.files.len(), 1);
1527 let bytes = observer
1528 .store()
1529 .read_blob(m.files[0].blob.as_deref().unwrap())
1530 .unwrap();
1531 assert_eq!(bytes, b"v1", "must keep the EARLIEST pre-image, not v2");
1532 std::fs::remove_dir_all(&root).ok();
1533 std::fs::remove_dir_all(&project).ok();
1534 }
1535
1536 #[tokio::test]
1537 async fn observer_begin_turn_clears_captured_set_for_a_new_turn() {
1538 let root = tmp("obs-newturn");
1539 let project = tmp("obs-newturn-project");
1540 std::fs::write(project.join("f.txt"), "v1").unwrap();
1541 let store = CheckpointStore::open(&root).unwrap();
1542 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1543 observer.begin_turn("turn 1");
1544 observer.before_write(&project.join("f.txt")).await;
1545 std::fs::write(project.join("f.txt"), "v2").unwrap();
1546 observer.begin_turn("turn 2");
1547 observer.before_write(&project.join("f.txt")).await;
1548 let id2 = observer.current().unwrap();
1549 let m2 = observer.store().manifest(&id2).unwrap();
1550 assert_eq!(m2.files.len(), 1);
1551 let bytes = observer
1552 .store()
1553 .read_blob(m2.files[0].blob.as_deref().unwrap())
1554 .unwrap();
1555 assert_eq!(
1556 bytes, b"v2",
1557 "turn 2's checkpoint must capture v2 as ITS pre-image"
1558 );
1559 std::fs::remove_dir_all(&root).ok();
1560 std::fs::remove_dir_all(&project).ok();
1561 }
1562
1563 #[test]
1564 fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
1565 assert!(is_protected(".git", &[]));
1566 assert!(is_protected(".git/config", &[]));
1567 assert!(is_protected(".git/objects/aa/bb", &[]));
1568 assert!(!is_protected(".gitignore", &[]));
1569 assert!(!is_protected("src/main.rs", &[]));
1570 }
1571
1572 #[test]
1573 fn contained_rejects_symlink_escape_for_an_existing_target() {
1574 let project = tmp("symlink-project");
1575 let outside = tmp("symlink-outside");
1576 std::fs::write(outside.join("secret.txt"), "s").unwrap();
1577 #[cfg(unix)]
1578 {
1579 std::os::unix::fs::symlink(outside.join("secret.txt"), project.join("link.txt"))
1580 .unwrap();
1581 assert!(!contained(&project, &project.join("link.txt")));
1582 }
1583 std::fs::remove_dir_all(&project).ok();
1584 std::fs::remove_dir_all(&outside).ok();
1585 }
1586
1587 #[test]
1588 fn contained_accepts_a_brand_new_file_inside_the_root() {
1589 let project = tmp("newfile-project");
1590 assert!(contained(&project, &project.join("does_not_exist_yet.txt")));
1591 assert!(contained(&project, &project.join("nested/dir/new.txt")));
1592 std::fs::remove_dir_all(&project).ok();
1593 }
1594}