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 restore(
334 &self,
335 id: &str,
336 project_root: &Path,
337 protected_globs: &[String],
338 ) -> Result<RestoreReport> {
339 let manifest = self.manifest(id)?;
340 let mut report = RestoreReport::default();
341 for entry in &manifest.files {
342 if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
351 report.refused.push((entry.path.clone(), reason));
352 continue;
353 }
354 let target = project_root.join(&entry.path);
355 let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
364 report.refused.push((
365 entry.path.clone(),
366 "refused: escapes the project root".to_string(),
367 ));
368 continue;
369 };
370 if is_protected(&normalized_rel, protected_globs) {
371 report
372 .refused
373 .push((entry.path.clone(), "refused: protected path".to_string()));
374 continue;
375 }
376 if !contained(project_root, &target) {
380 report.refused.push((
381 entry.path.clone(),
382 "refused: escapes the project root".to_string(),
383 ));
384 continue;
385 }
386 if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
403 if is_protected(&resolved_rel, protected_globs) {
404 report
405 .refused
406 .push((entry.path.clone(), "refused: protected path".to_string()));
407 continue;
408 }
409 }
410 match &entry.blob {
411 Some(hash) => {
412 let bytes = match self.read_blob(hash) {
413 Ok(b) => b,
414 Err(e) => {
415 report
416 .refused
417 .push((entry.path.clone(), format!("blob unreadable: {e}")));
418 continue;
419 }
420 };
421 if let Some(parent) = target.parent() {
422 let _ = std::fs::create_dir_all(parent);
423 }
424 if let Err(e) = std::fs::write(&target, &bytes) {
425 report
426 .refused
427 .push((entry.path.clone(), format!("write failed: {e}")));
428 continue;
429 }
430 }
431 None if target.exists() => {
432 if let Err(e) = std::fs::remove_file(&target) {
433 report
434 .refused
435 .push((entry.path.clone(), format!("delete failed: {e}")));
436 continue;
437 }
438 }
439 None => {}
440 }
441 report.restored.push(entry.path.clone());
442 }
443 Ok(report)
444 }
445
446 pub fn prune(&self, keep: usize) -> Result<usize> {
450 let mut metas = self.list()?; if metas.len() <= keep {
452 return Ok(0);
453 }
454 let stale = metas.split_off(keep);
455 let removed = stale.len();
456 for m in stale {
457 let _ = std::fs::remove_file(self.manifest_path(&m.id));
458 }
459 self.gc_unreferenced_blobs()?;
460 Ok(removed)
461 }
462
463 fn gc_unreferenced_blobs(&self) -> Result<()> {
468 let mut referenced: HashSet<String> = HashSet::new();
469 for meta in self.list()? {
470 if let Ok(m) = self.manifest(&meta.id) {
471 for f in m.files {
472 if let Some(b) = f.blob {
473 referenced.insert(b);
474 }
475 }
476 }
477 }
478 let objects = self.objects_dir();
479 if !objects.exists() {
480 return Ok(());
481 }
482 for entry in std::fs::read_dir(&objects)? {
483 let entry = entry?;
484 if !entry.file_type()?.is_dir() {
485 continue;
486 }
487 for inner in std::fs::read_dir(entry.path())? {
488 let inner = inner?;
489 let name = inner.file_name();
490 let Some(name) = name.to_str() else {
491 continue;
492 };
493 if name.starts_with(".tmp-") {
494 continue; }
496 if !referenced.contains(name) {
497 let _ = std::fs::remove_file(inner.path());
498 }
499 }
500 }
501 Ok(())
502 }
503}
504
505fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
531 crate::safe_path::is_protected(rel, extra_globs)
532}
533
534fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
546 crate::safe_path::reject_unsafe_rel_path(rel)
547}
548
549fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
559 crate::safe_path::normalized_project_rel(root, path)
560}
561
562fn contained(root: &Path, path: &Path) -> bool {
567 crate::safe_path::contained(root, path)
568}
569
570fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
586 crate::safe_path::resolved_project_rel(root, path)
587}
588
589#[derive(Debug, Default)]
590struct ObserverState {
591 current: Option<CheckpointId>,
593 captured: HashSet<String>,
597}
598
599#[derive(Debug)]
605pub struct CheckpointObserver {
606 store: CheckpointStore,
607 project_root: PathBuf,
608 retain: usize,
609 protected: Vec<String>,
610 state: Mutex<ObserverState>,
611 disabled: AtomicBool,
616}
617
618impl CheckpointObserver {
619 pub fn new(
624 store: CheckpointStore,
625 project_root: PathBuf,
626 retain: usize,
627 protected: Vec<String>,
628 ) -> Self {
629 CheckpointObserver {
630 store,
631 project_root,
632 retain: retain.max(1),
633 protected,
634 state: Mutex::new(ObserverState::default()),
635 disabled: AtomicBool::new(false),
636 }
637 }
638
639 pub fn store(&self) -> &CheckpointStore {
642 &self.store
643 }
644
645 pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
647 self.store.list()
648 }
649
650 pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
652 self.store.turn_diff(id)
653 }
654
655 pub fn restore(&self, id: &str) -> Result<RestoreReport> {
660 self.store.restore(id, &self.project_root, &self.protected)
661 }
662
663 pub fn is_disabled(&self) -> bool {
665 self.disabled.load(Ordering::Relaxed)
666 }
667
668 pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
678 if self.disabled.load(Ordering::Relaxed) {
679 return None;
680 }
681 let short: String = label.chars().take(120).collect();
682 match self.store.create_checkpoint(&short) {
683 Ok(id) => {
684 if let Ok(mut st) = self.state.lock() {
685 st.current = Some(id.clone());
686 st.captured.clear();
687 }
688 if let Err(e) = self.store.prune(self.retain) {
689 eprintln!("warning: checkpoint: prune failed: {e}");
690 }
691 Some(id)
692 }
693 Err(e) => {
694 eprintln!(
695 "warning: checkpoint disabled for the rest of this session — \
696 failed to open a new checkpoint: {e}"
697 );
698 self.disabled.store(true, Ordering::Relaxed);
699 None
700 }
701 }
702 }
703
704 pub fn current(&self) -> Option<CheckpointId> {
706 self.state.lock().ok().and_then(|s| s.current.clone())
707 }
708}
709
710#[async_trait::async_trait]
711impl WriteObserver for CheckpointObserver {
712 async fn before_write(&self, path: &Path) {
713 if self.disabled.load(Ordering::Relaxed) {
714 return;
715 }
716 if !contained(&self.project_root, path) {
717 return;
720 }
721 let Some(normalized) = crate::tools::normalize(path) else {
722 return;
723 };
724 let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
725 return;
726 };
727 let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
728 return;
729 };
730 let rel = rel_path.to_string_lossy().replace('\\', "/");
731 if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
732 return;
733 }
734
735 let mut st = match self.state.lock() {
742 Ok(g) => g,
743 Err(poisoned) => poisoned.into_inner(),
744 };
745 if st.current.is_none() {
746 drop(st);
752 self.begin_turn("untracked");
753 st = match self.state.lock() {
754 Ok(g) => g,
755 Err(poisoned) => poisoned.into_inner(),
756 };
757 }
758 if st.captured.contains(&rel) {
759 return;
760 }
761 let Some(id) = st.current.clone() else {
762 return; };
764 let content = std::fs::read(path).ok(); match self.store.record_pre_image(&id, &rel, content) {
766 Ok(()) => {
767 st.captured.insert(rel);
768 }
769 Err(e) => {
770 eprintln!(
771 "warning: checkpoint disabled for the rest of this session — \
772 failed to record a snapshot: {e}"
773 );
774 self.disabled.store(true, Ordering::Relaxed);
775 }
776 }
777 }
778
779 async fn after_write(&self, _path: &Path) -> Option<String> {
780 None
788 }
789}
790
791fn project_tag(cwd: &Path) -> String {
798 use std::hash::{Hash, Hasher};
799 let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
800 let mut h = std::collections::hash_map::DefaultHasher::new();
801 canon.hash(&mut h);
802 format!("{:016x}", h.finish())
803}
804
805fn default_shadow_root(cwd: &Path) -> PathBuf {
810 crate::agent::global_instructions_dir()
811 .join("checkpoints")
812 .join(project_tag(cwd))
813}
814
815pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
826 if !config.checkpoint_enabled {
827 return None;
828 }
829 let root = config
830 .checkpoint_dir
831 .clone()
832 .unwrap_or_else(|| default_shadow_root(&config.cwd));
833 match CheckpointStore::open(&root) {
834 Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::new(
835 store,
836 config.cwd.clone(),
837 config.checkpoint_retain,
838 config.permissions_protected_paths.clone(),
839 ))),
840 Err(e) => {
841 eprintln!(
842 "warning: [capabilities.checkpoint] is enabled but the shadow store at \
843 {} could not be opened — checkpoint is disabled for this session: {e}",
844 root.display()
845 );
846 None
847 }
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 fn tmp(tag: &str) -> PathBuf {
856 let dir = std::env::temp_dir().join(format!(
857 "supercode-checkpoint-test-{tag}-{}-{}",
858 std::process::id(),
859 mint_id()
860 ));
861 std::fs::create_dir_all(&dir).unwrap();
862 dir
863 }
864
865 #[test]
866 fn open_creates_objects_and_checkpoints_dirs() {
867 let root = tmp("open");
868 let store = CheckpointStore::open(&root).unwrap();
869 assert!(root.join("objects").is_dir());
870 assert!(root.join("checkpoints").is_dir());
871 drop(store);
872 std::fs::remove_dir_all(&root).ok();
873 }
874
875 #[test]
876 fn create_list_and_manifest_round_trip() {
877 let root = tmp("list");
878 let store = CheckpointStore::open(&root).unwrap();
879 let id1 = store.create_checkpoint("first turn").unwrap();
880 std::thread::sleep(std::time::Duration::from_millis(2));
881 let id2 = store.create_checkpoint("second turn").unwrap();
882 let metas = store.list().unwrap();
883 assert_eq!(metas.len(), 2);
884 assert_eq!(metas[0].id, id2);
886 assert_eq!(metas[1].id, id1);
887 assert_eq!(metas[0].label, "second turn");
888 let m = store.manifest(&id1).unwrap();
889 assert_eq!(m.id, id1);
890 assert!(m.files.is_empty());
891 std::fs::remove_dir_all(&root).ok();
892 }
893
894 #[test]
895 fn record_pre_image_is_idempotent_keeping_the_earliest() {
896 let root = tmp("idempotent");
897 let store = CheckpointStore::open(&root).unwrap();
898 let id = store.create_checkpoint("t").unwrap();
899 store
900 .record_pre_image(&id, "a.txt", Some(b"first".to_vec()))
901 .unwrap();
902 store
905 .record_pre_image(&id, "a.txt", Some(b"second".to_vec()))
906 .unwrap();
907 let m = store.manifest(&id).unwrap();
908 assert_eq!(m.files.len(), 1);
909 let bytes = store
910 .read_blob(m.files[0].blob.as_deref().unwrap())
911 .unwrap();
912 assert_eq!(bytes, b"first");
913 std::fs::remove_dir_all(&root).ok();
914 }
915
916 #[test]
917 fn blob_dedup_two_identical_contents_share_one_object() {
918 let root = tmp("dedup");
919 let store = CheckpointStore::open(&root).unwrap();
920 let id = store.create_checkpoint("t").unwrap();
921 store
922 .record_pre_image(&id, "a.txt", Some(b"same".to_vec()))
923 .unwrap();
924 store
925 .record_pre_image(&id, "b.txt", Some(b"same".to_vec()))
926 .unwrap();
927 let m = store.manifest(&id).unwrap();
928 assert_eq!(m.files[0].blob, m.files[1].blob);
929 let mut count = 0;
931 for entry in walkdir(&root.join("objects")) {
932 if entry.is_file() && !entry.to_string_lossy().contains(".tmp-") {
933 count += 1;
934 }
935 }
936 assert_eq!(count, 1);
937 std::fs::remove_dir_all(&root).ok();
938 }
939
940 fn walkdir(dir: &Path) -> Vec<PathBuf> {
941 let mut out = Vec::new();
942 let Ok(rd) = std::fs::read_dir(dir) else {
943 return out;
944 };
945 for entry in rd.flatten() {
946 let p = entry.path();
947 if p.is_dir() {
948 out.extend(walkdir(&p));
949 } else {
950 out.push(p);
951 }
952 }
953 out
954 }
955
956 #[test]
957 fn restore_rewrites_modified_and_deletes_created_files() {
958 let root = tmp("restore");
959 let project = tmp("restore-project");
960 std::fs::write(project.join("existing.txt"), "modified").unwrap();
961 let store = CheckpointStore::open(&root).unwrap();
962 let id = store.create_checkpoint("t").unwrap();
963 store
965 .record_pre_image(&id, "existing.txt", Some(b"original".to_vec()))
966 .unwrap();
967 store.record_pre_image(&id, "new.txt", None).unwrap();
969 std::fs::write(project.join("new.txt"), "brand new").unwrap();
970
971 let report = store.restore(&id, &project, &[]).unwrap();
972 assert!(report.refused.is_empty(), "{:?}", report.refused);
973 assert_eq!(report.restored.len(), 2);
974 assert_eq!(
975 std::fs::read_to_string(project.join("existing.txt")).unwrap(),
976 "original"
977 );
978 assert!(!project.join("new.txt").exists());
979 std::fs::remove_dir_all(&root).ok();
980 std::fs::remove_dir_all(&project).ok();
981 }
982
983 #[test]
984 fn restore_refuses_a_manifest_entry_that_traverses_outside_the_project_root() {
985 let root = tmp("hostile");
990 let project = tmp("hostile-project");
991 std::fs::create_dir_all(&project).unwrap();
992 let store = CheckpointStore::open(&root).unwrap();
993 let id = store.create_checkpoint("t").unwrap();
994 let mut m = store.manifest(&id).unwrap();
997 m.files.push(CheckpointFileEntry {
998 path: "../../../../../../etc/passwd-supercode-test".to_string(),
999 blob: None,
1000 });
1001 store.write_manifest(&m).unwrap();
1002
1003 let victim = project
1004 .parent()
1005 .unwrap()
1006 .parent()
1007 .unwrap()
1008 .join("etc/passwd-supercode-test");
1009 assert!(
1010 !victim.exists(),
1011 "test precondition: victim path must not already exist"
1012 );
1013
1014 let report = store.restore(&id, &project, &[]).unwrap();
1015 assert_eq!(report.restored.len(), 0);
1016 assert_eq!(report.refused.len(), 1);
1017 assert!(report.refused[0].1.contains("escapes"));
1018 assert!(
1019 !victim.exists(),
1020 "restore must never have written outside the project root"
1021 );
1022 std::fs::remove_dir_all(&root).ok();
1023 std::fs::remove_dir_all(&project).ok();
1024 }
1025
1026 #[test]
1027 fn restore_refuses_dot_git_unconditionally() {
1028 let root = tmp("gitfloor");
1029 let project = tmp("gitfloor-project");
1030 std::fs::create_dir_all(project.join(".git")).unwrap();
1031 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1032 let store = CheckpointStore::open(&root).unwrap();
1033 let id = store.create_checkpoint("t").unwrap();
1034 let mut m = store.manifest(&id).unwrap();
1035 m.files.push(CheckpointFileEntry {
1036 path: ".git/config".to_string(),
1037 blob: None,
1038 });
1039 store.write_manifest(&m).unwrap();
1040
1041 let report = store.restore(&id, &project, &[]).unwrap();
1042 assert_eq!(report.restored.len(), 0);
1043 assert_eq!(report.refused.len(), 1);
1044 assert!(report.refused[0].1.contains("protected"));
1045 assert_eq!(
1046 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1047 "real git config",
1048 "the real .git must be untouched"
1049 );
1050 std::fs::remove_dir_all(&root).ok();
1051 std::fs::remove_dir_all(&project).ok();
1052 }
1053
1054 #[test]
1055 fn restore_honors_extra_protected_globs() {
1056 let root = tmp("protectedglob");
1057 let project = tmp("protectedglob-project");
1058 std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1059 let store = CheckpointStore::open(&root).unwrap();
1060 let id = store.create_checkpoint("t").unwrap();
1061 let mut m = store.manifest(&id).unwrap();
1062 m.files.push(CheckpointFileEntry {
1063 path: ".env".to_string(),
1064 blob: Some(store.write_blob(b"OLD=1").unwrap()),
1065 });
1066 store.write_manifest(&m).unwrap();
1067
1068 let report = store
1069 .restore(&id, &project, &[".env*".to_string()])
1070 .unwrap();
1071 assert_eq!(report.restored.len(), 0);
1072 assert_eq!(report.refused.len(), 1);
1073 assert_eq!(
1074 std::fs::read_to_string(project.join(".env")).unwrap(),
1075 "SECRET=1"
1076 );
1077 std::fs::remove_dir_all(&root).ok();
1078 std::fs::remove_dir_all(&project).ok();
1079 }
1080
1081 #[test]
1082 fn restore_refuses_traversal_into_dot_git_real_git_config_stays_untouched() {
1083 let root = tmp("traversal-git");
1093 let project = tmp("traversal-git-project");
1094 std::fs::create_dir_all(project.join(".git")).unwrap();
1095 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1096 let store = CheckpointStore::open(&root).unwrap();
1097 let id = store.create_checkpoint("t").unwrap();
1098 let mut m = store.manifest(&id).unwrap();
1099 m.files.push(CheckpointFileEntry {
1100 path: "x/../.git/config".to_string(),
1101 blob: Some(store.write_blob(b"PWNED-by-traversal").unwrap()),
1102 });
1103 store.write_manifest(&m).unwrap();
1104
1105 let report = store.restore(&id, &project, &[]).unwrap();
1106 assert_eq!(report.restored.len(), 0, "must not restore into .git");
1107 assert_eq!(report.refused.len(), 1);
1108 assert!(
1109 report.refused[0].1.contains("escapes"),
1110 "unexpected refusal reason: {}",
1111 report.refused[0].1
1112 );
1113 assert_eq!(
1114 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1115 "real git config",
1116 "the real .git/config must be untouched by the traversal entry"
1117 );
1118 std::fs::remove_dir_all(&root).ok();
1119 std::fs::remove_dir_all(&project).ok();
1120 }
1121
1122 #[test]
1123 fn restore_refuses_traversal_bypass_of_protected_globs_dot_env_stays_untouched() {
1124 let root = tmp("traversal-env");
1129 let project = tmp("traversal-env-project");
1130 std::fs::create_dir_all(&project).unwrap();
1131 std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1132 let store = CheckpointStore::open(&root).unwrap();
1133 let id = store.create_checkpoint("t").unwrap();
1134 let mut m = store.manifest(&id).unwrap();
1135 m.files.push(CheckpointFileEntry {
1136 path: "x/../.env".to_string(),
1137 blob: Some(store.write_blob(b"PWNED=1").unwrap()),
1138 });
1139 store.write_manifest(&m).unwrap();
1140
1141 let report = store
1142 .restore(&id, &project, &[".env*".to_string()])
1143 .unwrap();
1144 assert_eq!(report.restored.len(), 0);
1145 assert_eq!(report.refused.len(), 1);
1146 assert_eq!(
1147 std::fs::read_to_string(project.join(".env")).unwrap(),
1148 "SECRET=1",
1149 "the real .env must be untouched by the traversal entry"
1150 );
1151 std::fs::remove_dir_all(&root).ok();
1152 std::fs::remove_dir_all(&project).ok();
1153 }
1154
1155 #[test]
1156 fn restore_refuses_an_absolute_manifest_path() {
1157 let root = tmp("absolute");
1168 let project = tmp("absolute-project");
1169 std::fs::create_dir_all(project.join(".git")).unwrap();
1170 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1171 let store = CheckpointStore::open(&root).unwrap();
1172 let id = store.create_checkpoint("t").unwrap();
1173 let absolute_git_config = project.join(".git").join("config");
1174 let mut m = store.manifest(&id).unwrap();
1175 m.files.push(CheckpointFileEntry {
1176 path: absolute_git_config.to_string_lossy().to_string(),
1177 blob: Some(store.write_blob(b"PWNED-by-absolute-path").unwrap()),
1178 });
1179 store.write_manifest(&m).unwrap();
1180
1181 let report = store.restore(&id, &project, &[]).unwrap();
1182 assert_eq!(report.restored.len(), 0);
1183 assert_eq!(report.refused.len(), 1);
1184 assert!(
1185 report.refused[0].1.contains("escapes"),
1186 "unexpected refusal reason: {}",
1187 report.refused[0].1
1188 );
1189 assert_eq!(
1190 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1191 "real git config"
1192 );
1193 std::fs::remove_dir_all(&root).ok();
1194 std::fs::remove_dir_all(&project).ok();
1195 }
1196
1197 #[test]
1198 fn restore_still_restores_a_clean_relative_nested_entry() {
1199 let root = tmp("cleanroundtrip");
1204 let project = tmp("cleanroundtrip-project");
1205 std::fs::create_dir_all(project.join("src")).unwrap();
1206 std::fs::write(project.join("src").join("main.rs"), "fn main() {}").unwrap();
1207 let store = CheckpointStore::open(&root).unwrap();
1208 let id = store.create_checkpoint("t").unwrap();
1209 store
1210 .record_pre_image(&id, "src/main.rs", Some(b"fn old() {}".to_vec()))
1211 .unwrap();
1212
1213 let report = store.restore(&id, &project, &[]).unwrap();
1214 assert!(report.refused.is_empty(), "{:?}", report.refused);
1215 assert_eq!(report.restored, vec!["src/main.rs".to_string()]);
1216 assert_eq!(
1217 std::fs::read_to_string(project.join("src").join("main.rs")).unwrap(),
1218 "fn old() {}"
1219 );
1220 std::fs::remove_dir_all(&root).ok();
1221 std::fs::remove_dir_all(&project).ok();
1222 }
1223
1224 #[test]
1225 #[cfg(unix)]
1226 fn restore_refuses_symlink_traversal_into_dot_git() {
1227 let root = tmp("symlink-traversal");
1242 let project = tmp("symlink-traversal-project");
1243 std::fs::create_dir_all(project.join(".git")).unwrap();
1244 std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1245 std::os::unix::fs::symlink(project.join(".git"), project.join("foo")).unwrap();
1246
1247 let store = CheckpointStore::open(&root).unwrap();
1248 let id = store.create_checkpoint("t").unwrap();
1249 let mut m = store.manifest(&id).unwrap();
1250 m.files.push(CheckpointFileEntry {
1251 path: "foo/config".to_string(),
1252 blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK").unwrap()),
1253 });
1254 store.write_manifest(&m).unwrap();
1255
1256 let report = store.restore(&id, &project, &[]).unwrap();
1257 assert_eq!(
1258 report.restored.len(),
1259 0,
1260 "must not restore through the symlink into .git"
1261 );
1262 assert_eq!(report.refused.len(), 1);
1263 assert!(
1264 report.refused[0].1.contains("protected"),
1265 "unexpected refusal reason: {}",
1266 report.refused[0].1
1267 );
1268 assert_eq!(
1269 std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1270 "real git config",
1271 "the real .git/config must be byte-identical — untouched by the symlink entry"
1272 );
1273 std::fs::remove_dir_all(&root).ok();
1274 std::fs::remove_dir_all(&project).ok();
1275 }
1276
1277 #[test]
1278 #[cfg(unix)]
1279 fn restore_refuses_symlink_traversal_into_a_protected_glob() {
1280 let root = tmp("symlink-glob");
1286 let project = tmp("symlink-glob-project");
1287 std::fs::create_dir_all(project.join("real_env")).unwrap();
1288 std::fs::write(project.join("real_env").join("creds"), "real secret").unwrap();
1289 std::os::unix::fs::symlink(project.join("real_env"), project.join("secrets")).unwrap();
1290
1291 let store = CheckpointStore::open(&root).unwrap();
1292 let id = store.create_checkpoint("t").unwrap();
1293 let mut m = store.manifest(&id).unwrap();
1294 m.files.push(CheckpointFileEntry {
1295 path: "secrets/creds".to_string(),
1296 blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK-GLOB").unwrap()),
1297 });
1298 store.write_manifest(&m).unwrap();
1299
1300 let report = store
1301 .restore(&id, &project, &["real_env/**".to_string()])
1302 .unwrap();
1303 assert_eq!(report.restored.len(), 0);
1304 assert_eq!(report.refused.len(), 1);
1305 assert!(
1306 report.refused[0].1.contains("protected"),
1307 "unexpected refusal reason: {}",
1308 report.refused[0].1
1309 );
1310 assert_eq!(
1311 std::fs::read_to_string(project.join("real_env").join("creds")).unwrap(),
1312 "real secret",
1313 "the real protected file must be untouched by the symlink entry"
1314 );
1315 std::fs::remove_dir_all(&root).ok();
1316 std::fs::remove_dir_all(&project).ok();
1317 }
1318
1319 #[test]
1320 fn prune_keeps_only_the_newest_and_gcs_unreferenced_blobs() {
1321 let root = tmp("prune");
1322 let store = CheckpointStore::open(&root).unwrap();
1323 for i in 0..5 {
1324 let id = store.create_checkpoint(&format!("turn {i}")).unwrap();
1325 store
1326 .record_pre_image(&id, "f.txt", Some(format!("content-{i}").into_bytes()))
1327 .unwrap();
1328 std::thread::sleep(std::time::Duration::from_millis(2));
1329 }
1330 assert_eq!(store.list().unwrap().len(), 5);
1331 let removed = store.prune(2).unwrap();
1332 assert_eq!(removed, 3);
1333 let remaining = store.list().unwrap();
1334 assert_eq!(remaining.len(), 2);
1335 assert_eq!(remaining[0].label, "turn 4");
1337 assert_eq!(remaining[1].label, "turn 3");
1338 for meta in &remaining {
1340 let m = store.manifest(&meta.id).unwrap();
1341 for f in &m.files {
1342 if let Some(hash) = &f.blob {
1343 store.read_blob(hash).unwrap();
1344 }
1345 }
1346 }
1347 std::fs::remove_dir_all(&root).ok();
1348 }
1349
1350 #[test]
1351 fn turn_diff_lists_exactly_the_captured_files() {
1352 let root = tmp("diff");
1353 let store = CheckpointStore::open(&root).unwrap();
1354 let id = store.create_checkpoint("t").unwrap();
1355 store.record_pre_image(&id, "a.rs", None).unwrap();
1356 store
1357 .record_pre_image(&id, "b.rs", Some(b"x".to_vec()))
1358 .unwrap();
1359 let mut diff = store.turn_diff(&id).unwrap();
1360 diff.sort();
1361 assert_eq!(diff, vec!["a.rs".to_string(), "b.rs".to_string()]);
1362 std::fs::remove_dir_all(&root).ok();
1363 }
1364
1365 #[test]
1366 fn manifest_rejects_a_path_traversing_id() {
1367 let root = tmp("badid");
1368 let store = CheckpointStore::open(&root).unwrap();
1369 assert!(store.manifest("../../etc/passwd").is_err());
1370 assert!(store.restore("../../etc/passwd", &root, &[]).is_err());
1371 std::fs::remove_dir_all(&root).ok();
1372 }
1373
1374 #[tokio::test]
1375 async fn observer_before_write_ignores_paths_outside_the_project_root() {
1376 let root = tmp("obs-outside");
1377 let project = tmp("obs-outside-project");
1378 let outside = tmp("obs-outside-elsewhere");
1379 std::fs::write(outside.join("victim.txt"), "do not touch").unwrap();
1380 let store = CheckpointStore::open(&root).unwrap();
1381 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1382 observer.begin_turn("t");
1383 observer.before_write(&outside.join("victim.txt")).await;
1384 let id = observer.current().unwrap();
1386 let diff = observer.store().turn_diff(&id).unwrap();
1387 assert!(
1388 diff.is_empty(),
1389 "must not capture writes outside project_root"
1390 );
1391 std::fs::remove_dir_all(&root).ok();
1392 std::fs::remove_dir_all(&project).ok();
1393 std::fs::remove_dir_all(&outside).ok();
1394 }
1395
1396 #[tokio::test]
1397 async fn observer_captures_only_the_first_write_to_a_path_in_a_turn() {
1398 let root = tmp("obs-firstwrite");
1399 let project = tmp("obs-firstwrite-project");
1400 std::fs::write(project.join("f.txt"), "v1").unwrap();
1401 let store = CheckpointStore::open(&root).unwrap();
1402 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1403 observer.begin_turn("t");
1404 observer.before_write(&project.join("f.txt")).await;
1405 std::fs::write(project.join("f.txt"), "v2").unwrap();
1406 observer.before_write(&project.join("f.txt")).await; let id = observer.current().unwrap();
1408 let m = observer.store().manifest(&id).unwrap();
1409 assert_eq!(m.files.len(), 1);
1410 let bytes = observer
1411 .store()
1412 .read_blob(m.files[0].blob.as_deref().unwrap())
1413 .unwrap();
1414 assert_eq!(bytes, b"v1", "must keep the EARLIEST pre-image, not v2");
1415 std::fs::remove_dir_all(&root).ok();
1416 std::fs::remove_dir_all(&project).ok();
1417 }
1418
1419 #[tokio::test]
1420 async fn observer_begin_turn_clears_captured_set_for_a_new_turn() {
1421 let root = tmp("obs-newturn");
1422 let project = tmp("obs-newturn-project");
1423 std::fs::write(project.join("f.txt"), "v1").unwrap();
1424 let store = CheckpointStore::open(&root).unwrap();
1425 let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1426 observer.begin_turn("turn 1");
1427 observer.before_write(&project.join("f.txt")).await;
1428 std::fs::write(project.join("f.txt"), "v2").unwrap();
1429 observer.begin_turn("turn 2");
1430 observer.before_write(&project.join("f.txt")).await;
1431 let id2 = observer.current().unwrap();
1432 let m2 = observer.store().manifest(&id2).unwrap();
1433 assert_eq!(m2.files.len(), 1);
1434 let bytes = observer
1435 .store()
1436 .read_blob(m2.files[0].blob.as_deref().unwrap())
1437 .unwrap();
1438 assert_eq!(
1439 bytes, b"v2",
1440 "turn 2's checkpoint must capture v2 as ITS pre-image"
1441 );
1442 std::fs::remove_dir_all(&root).ok();
1443 std::fs::remove_dir_all(&project).ok();
1444 }
1445
1446 #[test]
1447 fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
1448 assert!(is_protected(".git", &[]));
1449 assert!(is_protected(".git/config", &[]));
1450 assert!(is_protected(".git/objects/aa/bb", &[]));
1451 assert!(!is_protected(".gitignore", &[]));
1452 assert!(!is_protected("src/main.rs", &[]));
1453 }
1454
1455 #[test]
1456 fn contained_rejects_symlink_escape_for_an_existing_target() {
1457 let project = tmp("symlink-project");
1458 let outside = tmp("symlink-outside");
1459 std::fs::write(outside.join("secret.txt"), "s").unwrap();
1460 #[cfg(unix)]
1461 {
1462 std::os::unix::fs::symlink(outside.join("secret.txt"), project.join("link.txt"))
1463 .unwrap();
1464 assert!(!contained(&project, &project.join("link.txt")));
1465 }
1466 std::fs::remove_dir_all(&project).ok();
1467 std::fs::remove_dir_all(&outside).ok();
1468 }
1469
1470 #[test]
1471 fn contained_accepts_a_brand_new_file_inside_the_root() {
1472 let project = tmp("newfile-project");
1473 assert!(contained(&project, &project.join("does_not_exist_yet.txt")));
1474 assert!(contained(&project, &project.join("nested/dir/new.txt")));
1475 std::fs::remove_dir_all(&project).ok();
1476 }
1477}