use crate::error::{NonoError, Result};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use super::exclusion::ExclusionFilter;
use super::merkle::MerkleTree;
use super::object_store::ObjectStore;
use super::types::{Change, ChangeType, FileState, SessionMetadata, SnapshotManifest};
pub struct WalkBudget {
pub max_entries: usize,
pub max_bytes: u64,
}
impl Default for WalkBudget {
fn default() -> Self {
Self {
max_entries: 300_000,
max_bytes: 2 * 1024 * 1024 * 1024, }
}
}
pub struct SnapshotManager {
session_dir: PathBuf,
tracked_paths: Vec<PathBuf>,
exclusion: ExclusionFilter,
object_store: ObjectStore,
snapshot_count: u32,
budget: WalkBudget,
}
impl SnapshotManager {
pub fn new(
session_dir: PathBuf,
tracked_paths: Vec<PathBuf>,
exclusion: ExclusionFilter,
budget: WalkBudget,
) -> Result<Self> {
let snapshots_dir = session_dir.join("snapshots");
let changes_dir = session_dir.join("changes");
fs::create_dir_all(&snapshots_dir).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to create snapshots directory {}: {}",
snapshots_dir.display(),
e
))
})?;
fs::create_dir_all(&changes_dir).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to create changes directory {}: {}",
changes_dir.display(),
e
))
})?;
let object_store = ObjectStore::new(session_dir.clone())?;
Ok(Self {
session_dir,
tracked_paths,
exclusion,
object_store,
snapshot_count: 0,
budget,
})
}
pub fn create_baseline(&mut self) -> Result<SnapshotManifest> {
let files = self.walk_and_store()?;
let merkle = MerkleTree::from_manifest(&files)?;
let manifest = SnapshotManifest {
number: 0,
timestamp: now_epoch_secs(),
parent: None,
files,
merkle_root: *merkle.root(),
};
self.save_manifest(&manifest)?;
self.snapshot_count = 1;
Ok(manifest)
}
pub fn create_incremental(
&mut self,
previous: &SnapshotManifest,
) -> Result<(SnapshotManifest, Vec<Change>)> {
let current_files = self.walk_and_store()?;
let changes = compute_changes(&previous.files, ¤t_files);
let merkle = MerkleTree::from_manifest(¤t_files)?;
let number = previous.number.saturating_add(1);
let manifest = SnapshotManifest {
number,
timestamp: now_epoch_secs(),
parent: Some(previous.number),
files: current_files,
merkle_root: *merkle.root(),
};
self.save_manifest(&manifest)?;
if !changes.is_empty() {
let changes_path = self
.session_dir
.join("changes")
.join(format!("{number:03}.json"));
let json = serde_json::to_string_pretty(&changes)
.map_err(|e| NonoError::Snapshot(format!("Failed to serialize changes: {e}")))?;
atomic_write(&changes_path, json.as_bytes())?;
}
self.snapshot_count = number.saturating_add(1);
Ok((manifest, changes))
}
pub fn compute_restore_diff(&self, manifest: &SnapshotManifest) -> Result<Vec<Change>> {
self.validate_manifest_paths(manifest)?;
let current_files = self.walk_current()?;
let mut changes = Vec::new();
for (path, state) in &manifest.files {
let needs_restore = match current_files.get(path) {
Some(current) => current.hash != state.hash,
None => true,
};
if needs_restore {
let change_type = if current_files.contains_key(path) {
ChangeType::Modified
} else {
ChangeType::Created
};
changes.push(Change {
path: path.clone(),
change_type,
size_delta: None,
old_hash: current_files.get(path).map(|s| s.hash),
new_hash: Some(state.hash),
});
}
}
for (path, state) in ¤t_files {
if !manifest.files.contains_key(path) {
changes.push(Change {
path: path.clone(),
change_type: ChangeType::Deleted,
size_delta: None,
old_hash: Some(state.hash),
new_hash: None,
});
}
}
changes.sort_by(|a, b| a.path.cmp(&b.path));
Ok(changes)
}
pub fn restore_to(&self, manifest: &SnapshotManifest) -> Result<Vec<Change>> {
self.validate_manifest_paths(manifest)?;
let current_files = self.walk_current()?;
let mut applied_changes = Vec::new();
for (path, state) in &manifest.files {
let needs_restore = match current_files.get(path) {
Some(current) => current.hash != state.hash,
None => true, };
if needs_restore {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to create directory {}: {e}",
parent.display()
))
})?;
}
self.object_store.retrieve_to(&state.hash, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(state.permissions & 0o0777);
if let Err(e) = fs::set_permissions(path, perms) {
tracing::warn!("Failed to set permissions on {}: {}", path.display(), e);
}
}
let change_type = if current_files.contains_key(path) {
ChangeType::Modified
} else {
ChangeType::Created
};
applied_changes.push(Change {
path: path.clone(),
change_type,
size_delta: None,
old_hash: current_files.get(path).map(|s| s.hash),
new_hash: Some(state.hash),
});
}
}
for path in current_files.keys() {
if !manifest.files.contains_key(path) {
if let Err(e) = fs::remove_file(path) {
tracing::warn!("Failed to remove {}: {}", path.display(), e);
} else {
applied_changes.push(Change {
path: path.clone(),
change_type: ChangeType::Deleted,
size_delta: None,
old_hash: current_files.get(path).map(|s| s.hash),
new_hash: None,
});
}
}
}
Ok(applied_changes)
}
#[must_use]
pub fn collect_atomic_temp_files(&self) -> HashSet<PathBuf> {
let mut files = HashSet::new();
let mut entries_visited: usize = 0;
for tracked in &self.tracked_paths {
if !tracked.exists() {
continue;
}
if tracked.is_file() {
if has_atomic_temp_suffix(tracked) {
files.insert(tracked.clone());
}
continue;
}
for entry in WalkDir::new(tracked)
.follow_links(false)
.into_iter()
.filter_entry(|e| !self.exclusion.is_excluded(e.path()))
.filter_map(|e| e.ok())
{
entries_visited = entries_visited.saturating_add(1);
if self.budget.max_entries > 0 && entries_visited > self.budget.max_entries {
tracing::warn!(
"Atomic temp file scan capped at {} entries (budget limit)",
entries_visited
);
return files;
}
let path = entry.path();
if !path.is_file() {
continue;
}
if has_atomic_temp_suffix(path) {
files.insert(path.to_path_buf());
}
}
}
files
}
pub fn cleanup_new_atomic_temp_files(&self, existing: &HashSet<PathBuf>) -> usize {
let mut removed = 0usize;
for path in self.collect_atomic_temp_files() {
if existing.contains(&path) {
continue;
}
match fs::remove_file(&path) {
Ok(()) => removed = removed.saturating_add(1),
Err(e) => tracing::warn!("Failed to remove temp file {}: {}", path.display(), e),
}
}
removed
}
pub fn load_manifest(&self, number: u32) -> Result<SnapshotManifest> {
let path = self
.session_dir
.join("snapshots")
.join(format!("{number:03}.json"));
let content = fs::read_to_string(&path).map_err(|e| {
NonoError::Snapshot(format!("Failed to read manifest {}: {e}", path.display()))
})?;
serde_json::from_str(&content).map_err(|e| {
NonoError::Snapshot(format!("Failed to parse manifest {}: {e}", path.display()))
})
}
pub fn save_session_metadata(&self, meta: &SessionMetadata) -> Result<()> {
let path = self.session_dir.join("session.json");
let json = serde_json::to_string_pretty(meta).map_err(|e| {
NonoError::Snapshot(format!("Failed to serialize session metadata: {e}"))
})?;
atomic_write(&path, json.as_bytes())
}
#[must_use]
pub fn snapshot_count(&self) -> u32 {
self.snapshot_count
}
pub fn load_session_metadata(session_dir: &Path) -> Result<SessionMetadata> {
let path = session_dir.join("session.json");
let content = fs::read_to_string(&path).map_err(|e| {
NonoError::SessionNotFound(format!(
"Failed to read session metadata {}: {e}",
path.display()
))
})?;
serde_json::from_str(&content).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to parse session metadata {}: {e}",
path.display()
))
})
}
pub fn load_manifest_from(session_dir: &Path, number: u32) -> Result<SnapshotManifest> {
let path = session_dir
.join("snapshots")
.join(format!("{number:03}.json"));
let content = fs::read_to_string(&path).map_err(|e| {
NonoError::Snapshot(format!("Failed to read manifest {}: {e}", path.display()))
})?;
serde_json::from_str(&content).map_err(|e| {
NonoError::Snapshot(format!("Failed to parse manifest {}: {e}", path.display()))
})
}
pub fn load_changes_from(session_dir: &Path, number: u32) -> Result<Vec<Change>> {
let path = session_dir
.join("changes")
.join(format!("{number:03}.json"));
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).map_err(|e| {
NonoError::Snapshot(format!("Failed to read changes {}: {e}", path.display()))
})?;
serde_json::from_str(&content).map_err(|e| {
NonoError::Snapshot(format!("Failed to parse changes {}: {e}", path.display()))
})
}
fn validate_manifest_paths(&self, manifest: &SnapshotManifest) -> Result<()> {
for path in manifest.files.keys() {
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(NonoError::Snapshot(format!(
"Manifest contains path with parent directory traversal: {}",
path.display()
)));
}
let within_tracked = self
.tracked_paths
.iter()
.any(|tracked| path.starts_with(tracked));
if !within_tracked {
return Err(NonoError::Snapshot(format!(
"Manifest contains path outside tracked directories: {}",
path.display()
)));
}
}
Ok(())
}
fn walk_and_store(&self) -> Result<HashMap<PathBuf, FileState>> {
let mut files = HashMap::new();
let mut entries_visited: usize = 0;
let mut total_bytes: u64 = 0;
for tracked in &self.tracked_paths {
if !tracked.exists() {
continue;
}
if tracked.is_file() {
if !self.exclusion.is_excluded(tracked) {
if let Ok(meta) = fs::metadata(tracked) {
let file_size = meta.len();
if self.budget.max_bytes > 0
&& total_bytes.saturating_add(file_size) > self.budget.max_bytes
{
return Err(NonoError::Snapshot(format!(
"Rollback budget exceeded: {} bytes tracked (limit: {} bytes). \
Consider adding exclusion patterns for large directories, \
or disable rollback with --no-rollback.",
total_bytes.saturating_add(file_size),
self.budget.max_bytes
)));
}
}
match self.hash_and_store_file(tracked) {
Ok(state) => {
entries_visited = entries_visited.saturating_add(1);
total_bytes = total_bytes.saturating_add(state.size);
self.check_budget(entries_visited, total_bytes)?;
files.insert(tracked.clone(), state);
}
Err(e) => {
tracing::warn!("Skipping unreadable file {}: {}", tracked.display(), e);
}
}
}
continue;
}
for entry in WalkDir::new(tracked)
.follow_links(false)
.into_iter()
.filter_entry(|e| !self.exclusion.is_excluded(e.path()))
.filter_map(|e| e.ok())
{
entries_visited = entries_visited.saturating_add(1);
self.check_budget(entries_visited, total_bytes)?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(meta) = fs::metadata(path) {
let file_size = meta.len();
if self.budget.max_bytes > 0
&& total_bytes.saturating_add(file_size) > self.budget.max_bytes
{
return Err(NonoError::Snapshot(format!(
"Rollback budget exceeded: {} bytes tracked (limit: {} bytes). \
Consider adding exclusion patterns for large directories, \
or disable rollback with --no-rollback.",
total_bytes.saturating_add(file_size),
self.budget.max_bytes
)));
}
}
match self.hash_and_store_file(path) {
Ok(state) => {
total_bytes = total_bytes.saturating_add(state.size);
self.check_budget(entries_visited, total_bytes)?;
files.insert(path.to_path_buf(), state);
}
Err(e) => {
tracing::warn!("Skipping unreadable file {}: {}", path.display(), e);
}
}
}
}
Ok(files)
}
fn walk_current(&self) -> Result<HashMap<PathBuf, FileState>> {
let mut files = HashMap::new();
let mut entries_visited: usize = 0;
let mut total_bytes: u64 = 0;
for tracked in &self.tracked_paths {
if !tracked.exists() {
continue;
}
if tracked.is_file() {
if !self.exclusion.is_excluded(tracked) {
if let Ok(state) = file_state_from_metadata(tracked) {
entries_visited = entries_visited.saturating_add(1);
total_bytes = total_bytes.saturating_add(state.size);
self.check_budget(entries_visited, total_bytes)?;
files.insert(tracked.clone(), state);
}
}
continue;
}
for entry in WalkDir::new(tracked)
.follow_links(false)
.into_iter()
.filter_entry(|e| !self.exclusion.is_excluded(e.path()))
.filter_map(|e| e.ok())
{
entries_visited = entries_visited.saturating_add(1);
self.check_budget(entries_visited, total_bytes)?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(state) = file_state_from_metadata(path) {
total_bytes = total_bytes.saturating_add(state.size);
self.check_budget(entries_visited, total_bytes)?;
files.insert(path.to_path_buf(), state);
}
}
}
Ok(files)
}
fn hash_and_store_file(&self, path: &Path) -> Result<FileState> {
let hash = self.object_store.store_file(path)?;
let metadata = fs::metadata(path).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to read metadata for {}: {e}",
path.display()
))
})?;
Ok(FileState {
hash,
size: metadata.len(),
mtime: metadata.mtime(),
permissions: metadata.mode(),
})
}
fn check_budget(&self, entries: usize, bytes: u64) -> Result<()> {
if self.budget.max_entries > 0 && entries > self.budget.max_entries {
return Err(NonoError::Snapshot(format!(
"Rollback budget exceeded: visited {} entries (limit: {}). \
Consider adding exclusion patterns for large directories, \
or disable rollback with --no-rollback.",
entries, self.budget.max_entries
)));
}
if self.budget.max_bytes > 0 && bytes > self.budget.max_bytes {
return Err(NonoError::Snapshot(format!(
"Rollback budget exceeded: {} bytes tracked (limit: {} bytes). \
Consider adding exclusion patterns for large directories, \
or disable rollback with --no-rollback.",
bytes, self.budget.max_bytes
)));
}
Ok(())
}
fn save_manifest(&self, manifest: &SnapshotManifest) -> Result<()> {
let path = self
.session_dir
.join("snapshots")
.join(format!("{:03}.json", manifest.number));
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| NonoError::Snapshot(format!("Failed to serialize manifest: {e}")))?;
atomic_write(&path, json.as_bytes())
}
}
fn compute_changes(
previous: &HashMap<PathBuf, FileState>,
current: &HashMap<PathBuf, FileState>,
) -> Vec<Change> {
let mut changes = Vec::new();
for (path, prev_state) in previous {
match current.get(path) {
Some(curr_state) => {
if prev_state.hash != curr_state.hash {
let size_delta = i64::try_from(curr_state.size).ok().and_then(|curr| {
i64::try_from(prev_state.size)
.ok()
.map(|prev| curr.saturating_sub(prev))
});
changes.push(Change {
path: path.clone(),
change_type: ChangeType::Modified,
size_delta,
old_hash: Some(prev_state.hash),
new_hash: Some(curr_state.hash),
});
} else if prev_state.permissions != curr_state.permissions {
changes.push(Change {
path: path.clone(),
change_type: ChangeType::PermissionsChanged,
size_delta: Some(0),
old_hash: Some(prev_state.hash),
new_hash: Some(curr_state.hash),
});
}
}
None => {
changes.push(Change {
path: path.clone(),
change_type: ChangeType::Deleted,
size_delta: i64::try_from(prev_state.size)
.ok()
.map(|s| s.saturating_neg()),
old_hash: Some(prev_state.hash),
new_hash: None,
});
}
}
}
for (path, curr_state) in current {
if !previous.contains_key(path) {
changes.push(Change {
path: path.clone(),
change_type: ChangeType::Created,
size_delta: i64::try_from(curr_state.size).ok(),
old_hash: None,
new_hash: Some(curr_state.hash),
});
}
}
changes.sort_by(|a, b| a.path.cmp(&b.path));
changes
}
fn file_state_from_metadata(path: &Path) -> Result<FileState> {
use sha2::{Digest, Sha256};
let content = fs::read(path)
.map_err(|e| NonoError::Snapshot(format!("Failed to read {}: {e}", path.display())))?;
let hash_bytes: [u8; 32] = Sha256::digest(&content).into();
let metadata = fs::metadata(path).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to read metadata for {}: {e}",
path.display()
))
})?;
Ok(FileState {
hash: super::types::ContentHash::from_bytes(hash_bytes),
size: metadata.len(),
mtime: metadata.mtime(),
permissions: metadata.mode(),
})
}
fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
NonoError::Snapshot(format!("Path has no parent directory: {}", path.display()))
})?;
let temp_path = parent.join(format!(
".tmp-{}-{}",
std::process::id(),
super::object_store::random_u32()
));
let write_result = (|| -> Result<()> {
let mut file = fs::File::create(&temp_path).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to create temp file {}: {e}",
temp_path.display()
))
})?;
use std::io::Write;
file.write_all(content).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to write temp file {}: {e}",
temp_path.display()
))
})?;
file.sync_all().map_err(|e| {
NonoError::Snapshot(format!(
"Failed to sync temp file {}: {e}",
temp_path.display()
))
})?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&temp_path);
return Err(e);
}
fs::rename(&temp_path, path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
NonoError::Snapshot(format!(
"Failed to rename {} to {}: {e}",
temp_path.display(),
path.display()
))
})
}
fn now_epoch_secs() -> String {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}
fn has_atomic_temp_suffix(path: &Path) -> bool {
let Some(filename) = path.file_name().and_then(|f| f.to_str()) else {
return false;
};
let Some((base, tail)) = filename.rsplit_once(".tmp.") else {
return false;
};
if base.is_empty() {
return false;
}
let Some((pid, ts)) = tail.split_once('.') else {
return false;
};
!pid.is_empty()
&& !ts.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
&& ts.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::undo::exclusion::ExclusionConfig;
use crate::undo::types::ContentHash;
use tempfile::TempDir;
fn setup_test_dir() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let tracked = dir.path().join("project");
fs::create_dir_all(&tracked).expect("create project dir");
fs::write(tracked.join("file1.txt"), b"hello world").expect("write file1");
fs::write(tracked.join("file2.txt"), b"goodbye world").expect("write file2");
(dir, tracked)
}
fn make_manager(session_dir: &Path, tracked: &Path) -> SnapshotManager {
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, tracked).expect("filter");
SnapshotManager::new(
session_dir.to_path_buf(),
vec![tracked.to_path_buf()],
filter,
WalkBudget::default(),
)
.expect("manager")
}
#[test]
fn baseline_captures_all_files() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let manifest = manager.create_baseline().expect("baseline");
assert_eq!(manifest.number, 0);
assert!(manifest.parent.is_none());
assert_eq!(manifest.files.len(), 2);
assert!(manifest.files.contains_key(&tracked.join("file1.txt")));
assert!(manifest.files.contains_key(&tracked.join("file2.txt")));
}
#[test]
fn incremental_detects_modification() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("file1.txt"), b"modified content").expect("modify");
let (manifest, changes) = manager.create_incremental(&baseline).expect("incremental");
assert_eq!(manifest.number, 1);
assert_eq!(manifest.parent, Some(0));
assert!(!changes.is_empty());
let modified = changes
.iter()
.find(|c| c.path == tracked.join("file1.txt"))
.expect("should find modified file");
assert_eq!(modified.change_type, ChangeType::Modified);
}
#[test]
fn incremental_detects_creation() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("new_file.txt"), b"new content").expect("create");
let (_manifest, changes) = manager.create_incremental(&baseline).expect("incremental");
let created = changes
.iter()
.find(|c| c.path == tracked.join("new_file.txt"))
.expect("should find created file");
assert_eq!(created.change_type, ChangeType::Created);
}
#[test]
fn incremental_detects_deletion() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::remove_file(tracked.join("file2.txt")).expect("delete");
let (_manifest, changes) = manager.create_incremental(&baseline).expect("incremental");
let deleted = changes
.iter()
.find(|c| c.path == tracked.join("file2.txt"))
.expect("should find deleted file");
assert_eq!(deleted.change_type, ChangeType::Deleted);
}
#[test]
fn restore_reverts_to_baseline() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("file1.txt"), b"modified").expect("modify");
fs::write(tracked.join("new.txt"), b"new file").expect("create");
fs::remove_file(tracked.join("file2.txt")).expect("delete");
let applied = manager.restore_to(&baseline).expect("restore");
assert!(!applied.is_empty());
let content = fs::read_to_string(tracked.join("file1.txt")).expect("read file1");
assert_eq!(content, "hello world");
let content = fs::read_to_string(tracked.join("file2.txt")).expect("read file2");
assert_eq!(content, "goodbye world");
assert!(!tracked.join("new.txt").exists());
}
#[test]
fn merkle_root_differs_between_snapshots() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("file1.txt"), b"changed").expect("modify");
let (incremental, _) = manager.create_incremental(&baseline).expect("incremental");
assert_ne!(baseline.merkle_root, incremental.merkle_root);
}
#[test]
fn manifest_roundtrip_via_disk() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
let loaded = manager.load_manifest(0).expect("load");
assert_eq!(loaded.number, baseline.number);
assert_eq!(loaded.files.len(), baseline.files.len());
assert_eq!(loaded.merkle_root, baseline.merkle_root);
}
#[test]
fn session_metadata_save() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
let meta = SessionMetadata {
session_id: "test-session".to_string(),
started: "2025-01-01T00:00:00Z".to_string(),
ended: Some("2025-01-01T00:01:00Z".to_string()),
command: vec!["bash".to_string(), "-c".to_string(), "echo hi".to_string()],
tracked_paths: vec![tracked.to_path_buf()],
snapshot_count: 2,
exit_code: Some(0),
merkle_roots: vec![baseline.merkle_root],
network_events: vec![],
};
manager.save_session_metadata(&meta).expect("save metadata");
let content =
fs::read_to_string(session_dir.join("session.json")).expect("read session.json");
let loaded: SessionMetadata = serde_json::from_str(&content).expect("parse session.json");
assert_eq!(loaded.session_id, "test-session");
assert_eq!(loaded.merkle_roots.len(), 1);
}
#[test]
fn compute_restore_diff_shows_changes_without_modifying_disk() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("file1.txt"), b"modified").expect("modify");
fs::write(tracked.join("new.txt"), b"new file").expect("create");
fs::remove_file(tracked.join("file2.txt")).expect("delete");
let diff = manager.compute_restore_diff(&baseline).expect("diff");
assert!(!diff.is_empty());
let content = fs::read_to_string(tracked.join("file1.txt")).expect("read");
assert_eq!(content, "modified");
assert!(tracked.join("new.txt").exists());
assert!(!tracked.join("file2.txt").exists());
}
#[test]
fn load_session_metadata_static() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
let meta = SessionMetadata {
session_id: "static-load-test".to_string(),
started: "2025-01-01T00:00:00Z".to_string(),
ended: None,
command: vec!["test".to_string()],
tracked_paths: vec![tracked.to_path_buf()],
snapshot_count: 1,
exit_code: None,
merkle_roots: vec![baseline.merkle_root],
network_events: vec![],
};
manager.save_session_metadata(&meta).expect("save");
let loaded = SnapshotManager::load_session_metadata(&session_dir).expect("load");
assert_eq!(loaded.session_id, "static-load-test");
}
#[test]
fn load_session_metadata_defaults_network_events_for_legacy_json() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
let legacy = serde_json::json!({
"session_id": "legacy-session",
"started": "2025-01-01T00:00:00Z",
"ended": null,
"command": ["test"],
"tracked_paths": [tracked],
"snapshot_count": 1,
"exit_code": 0,
"merkle_roots": [baseline.merkle_root.to_string()],
});
fs::write(
session_dir.join("session.json"),
serde_json::to_vec_pretty(&legacy).expect("serialize legacy metadata"),
)
.expect("write session metadata");
let loaded = SnapshotManager::load_session_metadata(&session_dir).expect("load");
assert!(loaded.network_events.is_empty());
}
#[test]
fn load_manifest_and_changes_static() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
fs::write(tracked.join("file1.txt"), b"modified").expect("modify");
let (_incr, _changes) = manager.create_incremental(&baseline).expect("incremental");
let manifest = SnapshotManager::load_manifest_from(&session_dir, 0).expect("load manifest");
assert_eq!(manifest.number, 0);
let changes = SnapshotManager::load_changes_from(&session_dir, 1).expect("load changes");
assert!(!changes.is_empty());
let baseline_changes =
SnapshotManager::load_changes_from(&session_dir, 0).expect("load baseline changes");
assert!(baseline_changes.is_empty());
}
#[test]
fn validate_manifest_rejects_parent_dir_traversal() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let manager = make_manager(&session_dir, &tracked);
let mut files = HashMap::new();
let evil_path = tracked
.join("subdir")
.join("..")
.join("..")
.join("etc")
.join("passwd");
files.insert(
evil_path,
FileState {
hash: ContentHash::from_bytes([0xde; 32]),
size: 100,
mtime: 0,
permissions: 0o644,
},
);
let manifest = SnapshotManifest {
number: 0,
parent: None,
files,
merkle_root: ContentHash::from_bytes([0; 32]),
timestamp: "2025-01-01T00:00:00Z".to_string(),
};
let result = manager.validate_manifest_paths(&manifest);
assert!(result.is_err());
let err_msg = result.expect_err("should reject").to_string();
assert!(
err_msg.contains("parent directory traversal"),
"Expected traversal error, got: {err_msg}"
);
}
#[test]
fn validate_manifest_rejects_path_outside_tracked() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let manager = make_manager(&session_dir, &tracked);
let mut files = HashMap::new();
files.insert(
PathBuf::from("/tmp/not-tracked/secret.txt"),
FileState {
hash: ContentHash::from_bytes([0xde; 32]),
size: 50,
mtime: 0,
permissions: 0o644,
},
);
let manifest = SnapshotManifest {
number: 0,
parent: None,
files,
merkle_root: ContentHash::from_bytes([0; 32]),
timestamp: "2025-01-01T00:00:00Z".to_string(),
};
let result = manager.validate_manifest_paths(&manifest);
assert!(result.is_err());
let err_msg = result.expect_err("should reject").to_string();
assert!(
err_msg.contains("outside tracked directories"),
"Expected outside-tracked error, got: {err_msg}"
);
}
#[test]
fn validate_manifest_accepts_valid_paths() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager(&session_dir, &tracked);
let baseline = manager.create_baseline().expect("baseline");
let result = manager.validate_manifest_paths(&baseline);
assert!(result.is_ok());
}
#[test]
fn cleanup_new_atomic_temp_files_removes_only_new_files() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let manager = make_manager(&session_dir, &tracked);
let preexisting = tracked.join("preexisting.txt.tmp.100.200");
fs::write(&preexisting, b"old temp").expect("write preexisting temp");
let before = manager.collect_atomic_temp_files();
let new_temp = tracked.join("new.txt.tmp.300.400");
fs::write(&new_temp, b"new temp").expect("write new temp");
let not_atomic = tracked.join("kept.tmp");
fs::write(¬_atomic, b"keep").expect("write non-atomic temp");
let removed = manager.cleanup_new_atomic_temp_files(&before);
assert_eq!(removed, 1);
assert!(preexisting.exists());
assert!(!new_temp.exists());
assert!(not_atomic.exists());
}
fn make_manager_with_budget(
session_dir: &Path,
tracked: &Path,
budget: WalkBudget,
) -> SnapshotManager {
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, tracked).expect("filter");
SnapshotManager::new(
session_dir.to_path_buf(),
vec![tracked.to_path_buf()],
filter,
budget,
)
.expect("manager")
}
#[test]
fn walk_budget_entry_limit_exceeded() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager_with_budget(
&session_dir,
&tracked,
WalkBudget {
max_entries: 1,
max_bytes: 0,
},
);
let result = manager.create_baseline();
let Err(err) = result else {
panic!("expected budget error, got Ok");
};
let err_msg = format!("{err}");
assert!(
err_msg.contains("budget exceeded"),
"Expected budget error, got: {err_msg}"
);
}
#[test]
fn walk_budget_byte_limit_exceeded() {
let (dir, tracked) = setup_test_dir();
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let mut manager = make_manager_with_budget(
&session_dir,
&tracked,
WalkBudget {
max_entries: 0,
max_bytes: 1,
},
);
let result = manager.create_baseline();
let Err(err) = result else {
panic!("expected budget error, got Ok");
};
let err_msg = format!("{err}");
assert!(
err_msg.contains("budget exceeded") || err_msg.contains("bytes tracked"),
"Expected budget error, got: {err_msg}"
);
}
#[test]
fn budget_checked_for_tracked_files() {
let dir = TempDir::new().expect("tempdir");
let tracked_file = dir.path().join("bigfile.txt");
fs::write(&tracked_file, b"some content here").expect("write file");
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
let mut manager = SnapshotManager::new(
session_dir,
vec![tracked_file],
filter,
WalkBudget {
max_entries: 0,
max_bytes: 1,
},
)
.expect("manager");
let result = manager.create_baseline();
let Err(err) = result else {
panic!("expected budget error, got Ok");
};
let err_msg = format!("{err}");
assert!(
err_msg.contains("budget exceeded") || err_msg.contains("bytes tracked"),
"Expected budget error for tracked file, got: {err_msg}"
);
}
#[test]
fn collect_atomic_temp_prunes_excluded_dirs() {
let dir = TempDir::new().expect("tempdir");
let tracked = dir.path().join("project");
fs::create_dir_all(tracked.join("excluded_dir")).expect("create excluded dir");
fs::write(
tracked.join("excluded_dir/file.txt.tmp.100.200"),
b"temp in excluded",
)
.expect("write excluded temp");
fs::write(tracked.join("visible.txt.tmp.100.200"), b"temp visible")
.expect("write visible temp");
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: vec!["excluded_dir".to_string()],
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, &tracked).expect("filter");
let manager = SnapshotManager::new(
session_dir,
vec![tracked.clone()],
filter,
WalkBudget::default(),
)
.expect("manager");
let temps = manager.collect_atomic_temp_files();
assert!(temps.contains(&tracked.join("visible.txt.tmp.100.200")));
assert!(!temps.contains(&tracked.join("excluded_dir/file.txt.tmp.100.200")));
}
#[test]
fn collect_atomic_temp_respects_budget() {
let dir = TempDir::new().expect("tempdir");
let tracked = dir.path().join("project");
fs::create_dir_all(&tracked).expect("create tracked");
for i in 0..10 {
fs::write(tracked.join(format!("file{i}.txt.tmp.100.200")), b"temp")
.expect("write temp");
}
let session_dir = dir.path().join("session");
fs::create_dir_all(&session_dir).expect("create session dir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, &tracked).expect("filter");
let manager = SnapshotManager::new(
session_dir,
vec![tracked],
filter,
WalkBudget {
max_entries: 3, max_bytes: 0,
},
)
.expect("manager");
let temps = manager.collect_atomic_temp_files();
assert!(
temps.len() < 10,
"Expected budget cap, got {} temps",
temps.len()
);
}
}