Skip to main content

navi_core/
sandbox.rs

1use sha2::{Digest, Sha256};
2use std::collections::{BTreeSet, HashSet};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6const MAX_SNAPSHOT_FILE_CONTENT_BYTES: u64 = 1_000_000; // 1MB
7
8// ── SnapshotEntry ──────────────────────────────────────────────────────────
9
10/// A single file entry within a workspace snapshot.
11#[derive(Debug, Clone)]
12pub struct SnapshotEntry {
13    /// Absolute path to the file at snapshot time.
14    pub path: PathBuf,
15    /// SHA-256 hex digest of the file content.
16    pub hash: String,
17    /// Full file content, or `None` for files > 1 MB.
18    pub content: Option<String>,
19}
20
21// ── WorkspaceSnapshot ──────────────────────────────────────────────────────
22
23/// Pre-execution snapshot of file state for a workspace.
24#[derive(Debug, Clone)]
25pub struct WorkspaceSnapshot {
26    /// Machine-readable identifier (e.g. `snap_1748300000`).
27    pub id: String,
28    /// Individual file entries captured at snapshot time.
29    pub entries: Vec<SnapshotEntry>,
30    /// The original root paths that were scanned. Used to discover new files
31    /// when computing changes or rolling back.
32    pub roots: Vec<PathBuf>,
33    /// Unix timestamp (seconds since epoch) when the snapshot was created.
34    pub created_at: u64,
35}
36
37impl WorkspaceSnapshot {
38    /// Returns `true` if `path` was one of the snapshotted entries.
39    pub fn is_path_in_entry(&self, path: &Path) -> bool {
40        self.entries.iter().any(|e| e.path == path)
41    }
42}
43
44// ── ChangeSet ──────────────────────────────────────────────────────────────
45
46/// Records what actually changed between a snapshot and the current filesystem.
47#[derive(Debug, Clone)]
48pub struct ChangeSet {
49    /// Files that exist on disk but were not present in the snapshot.
50    pub files_created: Vec<PathBuf>,
51    /// Files that were in the snapshot but have different content now.
52    pub files_modified: Vec<PathBuf>,
53    /// Files that were in the snapshot but no longer exist on disk.
54    pub files_deleted: Vec<PathBuf>,
55    /// Optional unified diff of all changes (requires diff crate; `None` for MVP).
56    pub diff: Option<String>,
57}
58
59impl ChangeSet {
60    /// Returns `true` when no changes were detected.
61    pub fn is_empty(&self) -> bool {
62        self.files_created.is_empty()
63            && self.files_modified.is_empty()
64            && self.files_deleted.is_empty()
65    }
66
67    /// Total number of changed files.
68    pub fn total(&self) -> usize {
69        self.files_created.len() + self.files_modified.len() + self.files_deleted.len()
70    }
71}
72
73// ── Sandbox Manager ────────────────────────────────────────────────────────
74
75/// Low-level sandbox operations: create snapshots, compute changes, and roll
76/// back file state.
77pub struct SandboxManager;
78
79impl SandboxManager {
80    /// Creates a snapshot of the given paths (files and directories).
81    ///
82    /// Directories are walked recursively (respecting common ignore patterns).
83    /// Files are hashed with SHA-256; content is stored for files <= 1 MB.
84    pub fn create_snapshot(paths: &[PathBuf]) -> WorkspaceSnapshot {
85        let mut entries = Vec::new();
86        let mut visited = BTreeSet::new();
87        let mut root_set = BTreeSet::new();
88
89        for p in paths {
90            if p.is_dir() {
91                root_set.insert(p.canonicalize().unwrap_or_else(|_| p.clone()));
92                collect_files(p, &mut entries, &mut visited);
93            } else if p.is_file() {
94                let snapshot_path = p.canonicalize().unwrap_or_else(|_| p.clone());
95                if let Some(parent) = snapshot_path.parent() {
96                    root_set.insert(parent.to_path_buf());
97                }
98                if visited.insert(snapshot_path.clone()) {
99                    entries.push(snapshot_file(&snapshot_path));
100                }
101            } else {
102                let parent = p.parent().unwrap_or(Path::new("."));
103                if let Ok(canon_parent) = parent.canonicalize() {
104                    root_set.insert(canon_parent);
105                } else {
106                    root_set.insert(parent.to_path_buf());
107                }
108            }
109        }
110
111        let created_at = SystemTime::now()
112            .duration_since(UNIX_EPOCH)
113            .map(|d| d.as_secs())
114            .unwrap_or(0);
115        let id = format!("snap_{}", created_at);
116
117        WorkspaceSnapshot {
118            id,
119            entries,
120            roots: root_set.into_iter().collect(),
121            created_at,
122        }
123    }
124
125    /// Compares the current filesystem state against a snapshot and returns a
126    /// `ChangeSet` describing what was added, modified, or deleted.
127    pub fn compute_changes(snapshot: &WorkspaceSnapshot) -> ChangeSet {
128        let mut files_created = Vec::new();
129        let mut files_modified = Vec::new();
130        let mut files_deleted = Vec::new();
131
132        // Build a set of paths that were in the snapshot.
133        let entry_paths: HashSet<&PathBuf> = snapshot.entries.iter().map(|e| &e.path).collect();
134
135        // Check every snapshot entry for modification or deletion.
136        for entry in &snapshot.entries {
137            if !entry.path.exists() {
138                files_deleted.push(entry.path.clone());
139            } else if entry.path.is_file() {
140                if let Ok(current) = hash_file(&entry.path) {
141                    if current != entry.hash {
142                        files_modified.push(entry.path.clone());
143                    }
144                }
145            }
146        }
147
148        // Re-scan root directories to discover newly-created files.
149        for root in &snapshot.roots {
150            if root.is_dir() {
151                find_new_files(root, &entry_paths, &mut files_created);
152            }
153        }
154
155        ChangeSet {
156            files_created,
157            files_modified,
158            files_deleted,
159            diff: None,
160        }
161    }
162
163    /// Rolls the workspace back to the state captured in `snapshot`.
164    ///
165    /// * Files that were in the snapshot but are now missing or modified are
166    ///   restored from the stored content.
167    /// * Files that exist on disk but were NOT in the snapshot are deleted.
168    ///
169    /// Returns an error when a file that needs restoration has no stored
170    /// content (i.e. it was larger than 1 MB).
171    pub fn rollback(snapshot: &WorkspaceSnapshot) -> Result<(), String> {
172        // Phase 1: restore every entry that is missing or modified.
173        for entry in &snapshot.entries {
174            let needs_restore = if !entry.path.exists() {
175                true
176            } else if entry.path.is_file() {
177                hash_file(&entry.path)
178                    .map(|h| h != entry.hash)
179                    .unwrap_or(false)
180            } else {
181                false
182            };
183
184            if needs_restore {
185                let content = entry.content.as_ref().ok_or_else(|| {
186                    format!(
187                        "cannot restore `{}`: content not available (file > 1 MB)",
188                        entry.path.display()
189                    )
190                })?;
191                if let Some(parent) = entry.path.parent() {
192                    std::fs::create_dir_all(parent).map_err(|e| {
193                        format!(
194                            "failed to create parent directory `{}`: {e}",
195                            parent.display()
196                        )
197                    })?;
198                }
199                std::fs::write(&entry.path, content)
200                    .map_err(|e| format!("failed to restore `{}`: {e}", entry.path.display()))?;
201            }
202        }
203
204        // Phase 2: delete files that were created after the snapshot.
205        let entry_paths: HashSet<&PathBuf> = snapshot.entries.iter().map(|e| &e.path).collect();
206        for root in &snapshot.roots {
207            if root.is_dir() {
208                delete_new_files(root, &entry_paths)?;
209            }
210        }
211
212        Ok(())
213    }
214
215    /// Checks whether the git index has staged (uncommitted) changes.
216    ///
217    /// Runs `git diff --cached --quiet` and returns `false` if git is not
218    /// available or the command fails.
219    pub fn has_staged_changes() -> bool {
220        let output = std::process::Command::new("git")
221            .args(["diff", "--cached", "--quiet"])
222            .stdout(std::process::Stdio::null())
223            .stderr(std::process::Stdio::null())
224            .status();
225        match output {
226            Ok(status) => !status.success(),
227            Err(_) => false,
228        }
229    }
230}
231
232// ── Private helpers ────────────────────────────────────────────────────────
233
234/// Compute SHA-256 hex digest for a file's contents.
235fn hash_file(path: &Path) -> Result<String, String> {
236    let data =
237        std::fs::read(path).map_err(|e| format!("failed to read `{}`: {e}", path.display()))?;
238    let mut hasher = Sha256::new();
239    hasher.update(&data);
240    Ok(hex::encode(hasher.finalize()))
241}
242
243/// Build a `SnapshotEntry` for a single file at `path`.
244fn snapshot_file(path: &Path) -> SnapshotEntry {
245    let data = std::fs::read(path).unwrap_or_default();
246    let hash = {
247        let mut hasher = Sha256::new();
248        hasher.update(&data);
249        hex::encode(hasher.finalize())
250    };
251    let content = if data.len() as u64 <= MAX_SNAPSHOT_FILE_CONTENT_BYTES {
252        String::from_utf8(data).ok()
253    } else {
254        None
255    };
256    SnapshotEntry {
257        path: path.to_path_buf(),
258        hash,
259        content,
260    }
261}
262
263/// Recursively collect all files under `dir` and build snapshot entries.
264fn collect_files(dir: &Path, entries: &mut Vec<SnapshotEntry>, visited: &mut BTreeSet<PathBuf>) {
265    let Ok(iter) = std::fs::read_dir(dir) else {
266        return;
267    };
268    for entry in iter {
269        let Ok(entry) = entry else {
270            continue;
271        };
272        let path = entry.path();
273        if !visited.insert(path.clone()) {
274            continue;
275        }
276        if path.is_dir() {
277            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
278            if !matches!(
279                name,
280                ".git" | "target" | "node_modules" | ".cache" | ".venv" | "venv" | "__pycache__"
281            ) {
282                collect_files(&path, entries, visited);
283            }
284        } else if path.is_file() {
285            entries.push(snapshot_file(&path));
286        }
287    }
288}
289
290/// Find files under `dir` that were not in the original snapshot.
291fn find_new_files(dir: &Path, snapshot_paths: &HashSet<&PathBuf>, created: &mut Vec<PathBuf>) {
292    let Ok(iter) = std::fs::read_dir(dir) else {
293        return;
294    };
295    for entry in iter {
296        let Ok(entry) = entry else {
297            continue;
298        };
299        let path = entry.path();
300        if path.is_dir() {
301            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
302            if !matches!(name, ".git" | "target" | "node_modules") {
303                find_new_files(&path, snapshot_paths, created);
304            }
305        } else if path.is_file() && !snapshot_paths.contains(&path) {
306            created.push(path);
307        }
308    }
309}
310
311/// Delete files under `dir` that were not in the original snapshot.
312fn delete_new_files(dir: &Path, snapshot_paths: &HashSet<&PathBuf>) -> Result<(), String> {
313    let Ok(iter) = std::fs::read_dir(dir) else {
314        return Ok(());
315    };
316    for entry in iter {
317        let Ok(entry) = entry else {
318            continue;
319        };
320        let path = entry.path();
321        if path.is_dir() {
322            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
323            if !matches!(name, ".git" | "target" | "node_modules") {
324                delete_new_files(&path, snapshot_paths)?;
325            }
326            // Remove empty directories left behind.
327            let _ = std::fs::remove_dir(&path);
328        } else if path.is_file() && !snapshot_paths.contains(&path) {
329            std::fs::remove_file(&path)
330                .map_err(|e| format!("failed to delete `{}`: {e}", path.display()))?;
331        }
332    }
333    Ok(())
334}
335
336// ── Tests ──────────────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::fs;
342
343    fn write(path: &Path, content: &str) {
344        if let Some(parent) = path.parent() {
345            fs::create_dir_all(parent).unwrap();
346        }
347        fs::write(path, content).unwrap();
348    }
349
350    // ── Snapshot creation ─────────────────────────────────────────────────
351
352    #[test]
353    fn snapshot_captures_file_state() {
354        let dir = tempfile::tempdir().expect("tempdir");
355        let f1 = dir.path().join("a.txt");
356        let f2 = dir.path().join("b.txt");
357        write(&f1, "hello");
358        write(&f2, "world");
359
360        let snap = SandboxManager::create_snapshot(&[f1.clone(), f2.clone()]);
361        assert_eq!(snap.entries.len(), 2);
362        assert!(snap.entries.iter().any(|e| e.path == f1));
363        assert!(snap.entries.iter().any(|e| e.path == f2));
364        assert!(snap.entries.iter().all(|e| !e.hash.is_empty()));
365        assert!(snap.entries.iter().all(|e| e.content.is_some()));
366        assert!(snap.created_at > 0);
367    }
368
369    #[test]
370    fn snapshot_captures_directory_recursively() {
371        let dir = tempfile::tempdir().expect("tempdir");
372        let sub = dir.path().join("a").join("b");
373        fs::create_dir_all(&sub).unwrap();
374        write(&sub.join("deep.txt"), "deep");
375        write(&dir.path().join("root.txt"), "root");
376
377        let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
378        assert_eq!(snap.entries.len(), 2);
379        assert!(snap.entries.iter().any(|e| e.path == sub.join("deep.txt")));
380        assert!(
381            snap.entries
382                .iter()
383                .any(|e| e.path == dir.path().join("root.txt"))
384        );
385    }
386
387    #[test]
388    fn snapshot_skips_git_directory() {
389        let dir = tempfile::tempdir().expect("tempdir");
390        let git = dir.path().join(".git");
391        fs::create_dir_all(&git).unwrap();
392        write(&git.join("HEAD"), "ref: refs/heads/main");
393        write(&dir.path().join("actual.txt"), "real");
394
395        let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
396        assert_eq!(snap.entries.len(), 1);
397        assert!(
398            snap.entries
399                .iter()
400                .any(|e| e.path == dir.path().join("actual.txt"))
401        );
402    }
403
404    // ── Rollback: restore modified files ─────────────────────────────────
405
406    #[test]
407    fn rollback_restores_modified_file() {
408        let dir = tempfile::tempdir().expect("tempdir");
409        let f = dir.path().join("test.txt");
410        write(&f, "original");
411
412        let snap = SandboxManager::create_snapshot(&[f.clone()]);
413        write(&f, "modified content");
414        assert_eq!(fs::read_to_string(&f).unwrap(), "modified content");
415
416        SandboxManager::rollback(&snap).unwrap();
417        assert_eq!(fs::read_to_string(&f).unwrap(), "original");
418    }
419
420    #[test]
421    fn rollback_restores_deleted_file() {
422        let dir = tempfile::tempdir().expect("tempdir");
423        let f = dir.path().join("gone.txt");
424        write(&f, "will be restored");
425
426        let snap = SandboxManager::create_snapshot(&[f.clone()]);
427        fs::remove_file(&f).unwrap();
428        assert!(!f.exists());
429
430        SandboxManager::rollback(&snap).unwrap();
431        assert!(f.exists());
432        assert_eq!(fs::read_to_string(&f).unwrap(), "will be restored");
433    }
434
435    // ── Rollback: delete created files ────────────────────────────────────
436
437    #[test]
438    fn rollback_deletes_created_file() {
439        let dir = tempfile::tempdir().expect("tempdir");
440        let existing = dir.path().join("keep.txt");
441        write(&existing, "original");
442
443        let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
444        let created = dir.path().join("created.txt");
445        write(&created, "new file");
446        assert!(created.exists());
447
448        SandboxManager::rollback(&snap).unwrap();
449        assert!(!created.exists(), "created file should be deleted");
450        assert!(existing.exists(), "existing file should be kept");
451    }
452
453    #[test]
454    fn rollback_deletes_file_created_at_snapshotted_future_path() {
455        let dir = tempfile::tempdir().expect("tempdir");
456        let created = dir.path().join("future").join("created.txt");
457
458        let snap = SandboxManager::create_snapshot(std::slice::from_ref(&created));
459        write(&created, "new file");
460        assert!(created.exists());
461
462        SandboxManager::rollback(&snap).unwrap();
463        assert!(!created.exists(), "created file should be deleted");
464    }
465
466    #[test]
467    fn rollback_restores_modified_and_deletes_created_in_single_call() {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let a = dir.path().join("a.txt");
470        write(&a, "a original");
471
472        let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
473        write(&a, "a modified");
474        let b = dir.path().join("b.txt");
475        write(&b, "b new");
476
477        SandboxManager::rollback(&snap).unwrap();
478        assert_eq!(fs::read_to_string(&a).unwrap(), "a original");
479        assert!(!b.exists());
480    }
481
482    // ── Rollback errors ───────────────────────────────────────────────────
483
484    #[test]
485    fn rollback_without_content_returns_error() {
486        let dir = tempfile::tempdir().expect("tempdir");
487        let f = dir.path().join("large.txt");
488        write(&f, "data");
489
490        let snap = WorkspaceSnapshot {
491            id: "test".into(),
492            entries: vec![SnapshotEntry {
493                path: f.clone(),
494                hash: hash_file(&f).unwrap(),
495                content: None,
496            }],
497            roots: vec![],
498            created_at: 0,
499        };
500
501        // Modify the file (can't restore because content is None).
502        write(&f, "modified");
503        let err = SandboxManager::rollback(&snap).unwrap_err();
504        assert!(err.contains("not available"), "error: {err}");
505    }
506
507    #[test]
508    fn snapshot_binary_content_is_not_lossy() {
509        let dir = tempfile::tempdir().expect("tempdir");
510        let f = dir.path().join("binary.bin");
511        fs::write(&f, [0xff, 0x00, 0x61]).unwrap();
512
513        let snap = SandboxManager::create_snapshot(std::slice::from_ref(&f));
514        assert_eq!(snap.entries.len(), 1);
515        assert!(
516            snap.entries[0].content.is_none(),
517            "binary content must not be stored through lossy UTF-8"
518        );
519    }
520
521    // ── Rollback: non-existent snapshot (empty) ───────────────────────────
522
523    #[test]
524    fn rollback_of_empty_snapshot_succeeds() {
525        let snap = WorkspaceSnapshot {
526            id: "empty".into(),
527            entries: vec![],
528            roots: vec![],
529            created_at: 0,
530        };
531        assert!(SandboxManager::rollback(&snap).is_ok());
532    }
533
534    // ── ChangeSet detection ───────────────────────────────────────────────
535
536    #[test]
537    fn compute_changes_detects_created_modified_deleted() {
538        let dir = tempfile::tempdir().expect("tempdir");
539        let a = dir.path().join("a.txt");
540        let b = dir.path().join("b.txt");
541        write(&a, "a content");
542        write(&b, "b content");
543
544        let snap = SandboxManager::create_snapshot(&[dir.path().to_path_buf()]);
545
546        // Modify a, delete b, create c.
547        write(&a, "a modified");
548        fs::remove_file(&b).unwrap();
549        let c = dir.path().join("c.txt");
550        write(&c, "c new");
551
552        let changes = SandboxManager::compute_changes(&snap);
553        assert!(
554            changes.files_modified.iter().any(|p| p == &a),
555            "a should be modified"
556        );
557        assert!(
558            changes.files_deleted.iter().any(|p| p == &b),
559            "b should be deleted"
560        );
561        assert!(
562            changes.files_created.iter().any(|p| p == &c),
563            "c should be created"
564        );
565    }
566
567    #[test]
568    fn compute_changes_empty_when_no_changes() {
569        let dir = tempfile::tempdir().expect("tempdir");
570        let f = dir.path().join("stable.txt");
571        write(&f, "stable");
572
573        let snap = SandboxManager::create_snapshot(&[f.clone()]);
574        let changes = SandboxManager::compute_changes(&snap);
575        assert!(changes.is_empty());
576        assert_eq!(changes.total(), 0);
577    }
578
579    // ── has_staged_changes ────────────────────────────────────────────────
580
581    #[test]
582    fn has_staged_changes_returns_bool_without_panicking() {
583        // Must not panic regardless of environment. Returns false when
584        // not in a git repo or when git index is clean.
585        let _ = SandboxManager::has_staged_changes();
586    }
587
588    // ── SnapshotEntry helpers ─────────────────────────────────────────────
589
590    #[test]
591    fn is_path_in_entry_works_correctly() {
592        let dir = tempfile::tempdir().expect("tempdir");
593        let a = dir.path().join("a.txt");
594        write(&a, "data");
595        let snap = SandboxManager::create_snapshot(&[a.clone()]);
596        assert!(snap.is_path_in_entry(&a));
597        assert!(!snap.is_path_in_entry(&dir.path().join("nonexistent.txt")));
598    }
599}