use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const BACKUP_DIR: &str = ".patchloom/backups";
const PRUNE_DAYS: u64 = 7;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub path: String,
pub action: FileAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Modified,
Created,
Deleted,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
pub timestamp: String,
pub entries: Vec<ManifestEntry>,
}
fn sanitize_rel_path(file_path: &Path, project_root: &Path) -> PathBuf {
if let Ok(rel) = file_path.strip_prefix(project_root) {
return rel.to_path_buf();
}
let s = file_path.to_string_lossy();
if let Some(rest) = s.strip_prefix('/') {
PathBuf::from(format!("__external__/{rest}"))
} else if s.len() >= 3
&& s.as_bytes()[1] == b':'
&& (s.as_bytes()[2] == b'\\' || s.as_bytes()[2] == b'/')
{
let drive = s.as_bytes()[0] as char;
let rest = &s[3..];
PathBuf::from(format!("__external_{drive}__/{rest}"))
} else {
PathBuf::from(format!("__external__/{s}"))
}
}
pub struct BackupSession {
session_dir: PathBuf,
project_root: PathBuf,
timestamp: String,
entries: Vec<ManifestEntry>,
}
impl BackupSession {
pub fn new(project_root: &Path) -> anyhow::Result<Self> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let timestamp = format!("{}", now.as_nanos());
let session_dir = project_root.join(BACKUP_DIR).join(×tamp);
std::fs::create_dir_all(&session_dir)
.with_context(|| format!("failed to create backup dir {}", session_dir.display()))?;
let _ = prune_old_backups(project_root);
Ok(Self {
session_dir,
project_root: project_root.to_path_buf(),
timestamp,
entries: Vec::new(),
})
}
pub fn save_before_write(&mut self, file_path: &Path) -> anyhow::Result<()> {
let rel = sanitize_rel_path(file_path, &self.project_root);
let rel_str = rel.to_string_lossy().to_string();
if self.entries.iter().any(|e| e.path == rel_str) {
return Ok(());
}
if file_path.exists() {
let backup_path = self.session_dir.join(&rel_str);
if let Some(parent) = backup_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(file_path, &backup_path).with_context(|| {
format!(
"failed to back up {} to {}",
file_path.display(),
backup_path.display()
)
})?;
self.entries.push(ManifestEntry {
path: rel_str,
action: FileAction::Modified,
});
} else {
self.entries.push(ManifestEntry {
path: rel_str,
action: FileAction::Created,
});
}
Ok(())
}
pub fn save_before_delete(&mut self, file_path: &Path) -> anyhow::Result<()> {
let rel = sanitize_rel_path(file_path, &self.project_root);
let rel_str = rel.to_string_lossy().to_string();
if self.entries.iter().any(|e| e.path == rel_str) {
return Ok(());
}
if file_path.exists() {
let backup_path = self.session_dir.join(&rel_str);
if let Some(parent) = backup_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(file_path, &backup_path)?;
}
self.entries.push(ManifestEntry {
path: rel_str,
action: FileAction::Deleted,
});
Ok(())
}
pub fn finalize(self) -> anyhow::Result<Option<String>> {
if self.entries.is_empty() {
let _ = std::fs::remove_dir(&self.session_dir);
return Ok(None);
}
let manifest = Manifest {
timestamp: self.timestamp.clone(),
entries: self.entries,
};
let manifest_path = self.session_dir.join("manifest.json");
let json = serde_json::to_string_pretty(&manifest)?;
std::fs::write(&manifest_path, json)
.with_context(|| format!("failed to write manifest {}", manifest_path.display()))?;
Ok(Some(self.timestamp))
}
}
pub fn backup_write_files(
cwd: &Path,
files: &[(&Path, &str, &crate::write::WritePolicy)],
) -> anyhow::Result<()> {
let mut session = BackupSession::new(cwd)?;
for &(path, _, _) in files {
session.save_before_write(path)?;
}
session.finalize()?;
for &(path, content, policy) in files {
crate::write::atomic_write(path, content, policy)?;
}
Ok(())
}
pub fn list_sessions(project_root: &Path) -> anyhow::Result<Vec<Manifest>> {
let backup_dir = project_root.join(BACKUP_DIR);
if !backup_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
for entry in entries {
let manifest_path = entry.path().join("manifest.json");
if manifest_path.exists() {
let content = std::fs::read_to_string(&manifest_path)?;
if let Ok(manifest) = serde_json::from_str::<Manifest>(&content) {
sessions.push(manifest);
}
}
}
Ok(sessions)
}
pub fn restore_session(project_root: &Path, timestamp: &str) -> anyhow::Result<usize> {
let session_dir = project_root.join(BACKUP_DIR).join(timestamp);
let manifest_path = session_dir.join("manifest.json");
let content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("no backup session found for {timestamp}"))?;
let manifest: Manifest = serde_json::from_str(&content)?;
let mut restored = 0;
for entry in &manifest.entries {
let target = resolve_restore_path(project_root, &entry.path);
validate_restore_path(&entry.path)?;
match entry.action {
FileAction::Modified => {
let backup = session_dir.join(&entry.path);
if backup.exists() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&backup, &target)?;
restored += 1;
}
}
FileAction::Created => {
if target.exists() {
std::fs::remove_file(&target)?;
restored += 1;
}
}
FileAction::Deleted => {
let backup = session_dir.join(&entry.path);
if backup.exists() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&backup, &target)?;
restored += 1;
}
}
}
}
Ok(restored)
}
fn validate_restore_path(entry_path: &str) -> anyhow::Result<()> {
let mut depth: i32 = 0;
for component in Path::new(entry_path).components() {
match component {
std::path::Component::ParentDir => {
depth -= 1;
if depth < 0 {
anyhow::bail!("restore path escapes project root: {entry_path}");
}
}
std::path::Component::Normal(_) => {
depth += 1;
}
std::path::Component::CurDir => {}
_ => {
anyhow::bail!("unexpected path component in restore path: {entry_path}");
}
}
}
Ok(())
}
fn resolve_restore_path(project_root: &Path, entry_path: &str) -> PathBuf {
if let Some(rest) = entry_path.strip_prefix("__external__/") {
PathBuf::from(format!("/{rest}"))
} else if entry_path.starts_with("__external_")
&& entry_path.len() > 14
&& entry_path
.as_bytes()
.get(11)
.is_some_and(|b| b.is_ascii_alphabetic())
&& entry_path[12..].starts_with("__/")
{
let drive = entry_path.as_bytes()[11] as char;
let rest = &entry_path[15..];
PathBuf::from(format!("{drive}:\\{rest}"))
} else {
project_root.join(entry_path)
}
}
pub fn prune_old_backups(project_root: &Path) -> anyhow::Result<usize> {
let backup_dir = project_root.join(BACKUP_DIR);
if !backup_dir.exists() {
return Ok(0);
}
let cutoff =
std::time::SystemTime::now() - std::time::Duration::from_secs(PRUNE_DAYS * 24 * 60 * 60);
let mut pruned = 0;
for entry in std::fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()) {
if let Ok(meta) = entry.metadata()
&& let Ok(modified) = meta.modified()
&& modified < cutoff
{
let _ = std::fs::remove_dir_all(entry.path());
pruned += 1;
}
}
Ok(pruned)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn backup_and_restore_modified_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, "original content").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
std::fs::write(&file, "modified content").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "modified content");
let restored = restore_session(dir.path(), &ts).unwrap();
assert_eq!(restored, 1);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "original content");
}
#[test]
fn backup_and_restore_created_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("new.txt");
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
std::fs::write(&file, "new content").unwrap();
assert!(file.exists());
let restored = restore_session(dir.path(), &ts).unwrap();
assert_eq!(restored, 1);
assert!(!file.exists());
}
#[test]
fn backup_and_restore_deleted_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doomed.txt");
std::fs::write(&file, "doomed content").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_delete(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
std::fs::remove_file(&file).unwrap();
assert!(!file.exists());
let restored = restore_session(dir.path(), &ts).unwrap();
assert_eq!(restored, 1);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "doomed content");
}
#[test]
fn list_sessions_returns_newest_first() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "v1").unwrap();
let mut s1 = BackupSession::new(dir.path()).unwrap();
s1.save_before_write(&file).unwrap();
let ts1 = s1.finalize().unwrap().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&file, "v2").unwrap();
let mut s2 = BackupSession::new(dir.path()).unwrap();
s2.save_before_write(&file).unwrap();
let ts2 = s2.finalize().unwrap().unwrap();
assert_ne!(ts1, ts2, "timestamps must differ");
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].timestamp, ts2);
assert_eq!(sessions[1].timestamp, ts1);
}
#[test]
fn empty_session_cleans_up() {
let dir = TempDir::new().unwrap();
let session = BackupSession::new(dir.path()).unwrap();
let result = session.finalize().unwrap();
assert!(result.is_none());
}
#[test]
fn duplicate_save_ignored() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("dup.txt");
std::fs::write(&file, "original").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions[0].entries.len(), 1);
std::fs::write(&file, "changed").unwrap();
restore_session(dir.path(), &ts).unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
}
#[test]
fn prune_old_backups_removes_stale_sessions() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "v1").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
let session_dir = dir.path().join(BACKUP_DIR).join(&ts);
let eight_days_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60);
let times = std::fs::FileTimes::new().set_modified(eight_days_ago);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
let f = std::fs::OpenOptions::new()
.write(true)
.custom_flags(0x02000000) .open(&session_dir)
.unwrap();
f.set_times(times).unwrap();
}
#[cfg(not(windows))]
{
let f = std::fs::File::open(&session_dir).unwrap();
f.set_times(times).unwrap();
}
let pruned = prune_old_backups(dir.path()).unwrap();
assert_eq!(pruned, 1);
let sessions = list_sessions(dir.path()).unwrap();
assert!(sessions.is_empty());
}
#[test]
fn prune_old_backups_keeps_recent_sessions() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "v1").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
session.finalize().unwrap().unwrap();
let pruned = prune_old_backups(dir.path()).unwrap();
assert_eq!(pruned, 0);
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 1);
}
#[test]
fn prune_old_backups_no_backup_dir() {
let dir = TempDir::new().unwrap();
let pruned = prune_old_backups(dir.path()).unwrap();
assert_eq!(pruned, 0);
}
#[test]
fn sanitize_rel_path_inside_project() {
let root = Path::new("/project");
let file = Path::new("/project/src/main.rs");
let rel = sanitize_rel_path(file, root);
assert_eq!(rel, PathBuf::from("src/main.rs"));
}
#[test]
fn sanitize_rel_path_outside_project() {
let root = Path::new("/project");
let file = Path::new("/tmp/other/file.txt");
let rel = sanitize_rel_path(file, root);
assert_eq!(rel, PathBuf::from("__external__/tmp/other/file.txt"));
}
#[test]
fn backup_file_outside_project_root() {
let project = TempDir::new().unwrap();
let external = TempDir::new().unwrap();
let ext_file = external.path().join("outside.txt");
std::fs::write(&ext_file, "external content").unwrap();
let mut session = BackupSession::new(project.path()).unwrap();
session.save_before_write(&ext_file).unwrap();
session.finalize().unwrap().unwrap();
assert_eq!(
std::fs::read_to_string(&ext_file).unwrap(),
"external content",
"original file must not be corrupted by backup"
);
let sessions = list_sessions(project.path()).unwrap();
assert_eq!(sessions.len(), 1);
assert!(
sessions[0].entries[0].path.starts_with("__external"),
"external file path should be under __external*/ (got: {})",
sessions[0].entries[0].path
);
}
#[test]
fn resolve_restore_path_internal() {
let root = Path::new("/project");
let p = resolve_restore_path(root, "src/main.rs");
assert_eq!(p, PathBuf::from("/project/src/main.rs"));
}
#[test]
fn resolve_restore_path_external_unix() {
let root = Path::new("/project");
let p = resolve_restore_path(root, "__external__/tmp/other/file.txt");
assert_eq!(p, PathBuf::from("/tmp/other/file.txt"));
}
#[test]
fn resolve_restore_path_external_windows() {
let root = Path::new("/project");
let p = resolve_restore_path(root, "__external_C__/Users/name/file.txt");
assert_eq!(p, PathBuf::from("C:\\Users/name/file.txt"));
}
#[test]
fn backup_and_restore_external_file() {
let project = TempDir::new().unwrap();
let external = TempDir::new().unwrap();
let ext_file = external.path().join("data.txt");
std::fs::write(&ext_file, "original external").unwrap();
let mut session = BackupSession::new(project.path()).unwrap();
session.save_before_write(&ext_file).unwrap();
let ts = session.finalize().unwrap().unwrap();
std::fs::write(&ext_file, "modified external").unwrap();
let restored = restore_session(project.path(), &ts).unwrap();
assert_eq!(restored, 1);
assert_eq!(
std::fs::read_to_string(&ext_file).unwrap(),
"original external"
);
}
#[test]
fn delete_backup_file_outside_project_root() {
let project = TempDir::new().unwrap();
let external = TempDir::new().unwrap();
let ext_file = external.path().join("doomed.txt");
std::fs::write(&ext_file, "doomed external").unwrap();
let mut session = BackupSession::new(project.path()).unwrap();
session.save_before_delete(&ext_file).unwrap();
session.finalize().unwrap().unwrap();
assert_eq!(
std::fs::read_to_string(&ext_file).unwrap(),
"doomed external"
);
}
#[test]
fn restore_rejects_path_traversal() {
let dir = TempDir::new().unwrap();
let ts = "999999999";
let session_dir = dir.path().join(BACKUP_DIR).join(ts);
std::fs::create_dir_all(&session_dir).unwrap();
let manifest = Manifest {
timestamp: ts.to_string(),
entries: vec![ManifestEntry {
path: "../../etc/passwd".to_string(),
action: FileAction::Modified,
}],
};
let json = serde_json::to_string_pretty(&manifest).unwrap();
std::fs::write(session_dir.join("manifest.json"), json).unwrap();
let result = restore_session(dir.path(), ts);
assert!(
result.is_err(),
"restore should reject path traversal, got: {:?}",
result
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("escapes project root"),
"error should mention escaping: {err}"
);
}
#[test]
fn restore_rejects_traversal_in_external_prefix() {
let dir = TempDir::new().unwrap();
let ts = "888888888";
let session_dir = dir.path().join(BACKUP_DIR).join(ts);
std::fs::create_dir_all(&session_dir).unwrap();
let manifest = Manifest {
timestamp: ts.to_string(),
entries: vec![ManifestEntry {
path: "__external__/../../../etc/shadow".to_string(),
action: FileAction::Modified,
}],
};
std::fs::write(
session_dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
let result = restore_session(dir.path(), ts);
assert!(
result.is_err(),
"external path with .. should be rejected, got: {result:?}"
);
}
#[test]
fn backup_write_files_backs_up_before_writing() {
let dir = TempDir::new().unwrap();
let f1 = dir.path().join("a.txt");
let f2 = dir.path().join("b.txt");
std::fs::write(&f1, "original-a").unwrap();
std::fs::write(&f2, "original-b").unwrap();
let policy = crate::write::WritePolicy::default();
let files: Vec<(&Path, &str, &crate::write::WritePolicy)> =
vec![(&f1, "new-a", &policy), (&f2, "new-b", &policy)];
backup_write_files(dir.path(), &files).unwrap();
assert_eq!(std::fs::read_to_string(&f1).unwrap(), "new-a");
assert_eq!(std::fs::read_to_string(&f2).unwrap(), "new-b");
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 1);
restore_session(dir.path(), &sessions[0].timestamp).unwrap();
assert_eq!(std::fs::read_to_string(&f1).unwrap(), "original-a");
assert_eq!(std::fs::read_to_string(&f2).unwrap(), "original-b");
}
#[test]
fn backup_write_files_manifest_survives_write_failure() {
let dir = TempDir::new().unwrap();
let real = dir.path().join("real.txt");
std::fs::write(&real, "original").unwrap();
let bad = dir.path().join("no_such_dir").join("fail.txt");
let policy = crate::write::WritePolicy::default();
let files: Vec<(&Path, &str, &crate::write::WritePolicy)> =
vec![(&real, "updated", &policy), (&bad, "x", &policy)];
let result = backup_write_files(dir.path(), &files);
assert!(result.is_err(), "write to missing dir should fail");
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 1, "backup session must be finalized");
assert_eq!(std::fs::read_to_string(&real).unwrap(), "updated");
restore_session(dir.path(), &sessions[0].timestamp).unwrap();
assert_eq!(std::fs::read_to_string(&real).unwrap(), "original");
}
}