use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use crate::containment::PathGuard;
pub const BACKUP_DIR: &str = ".patchloom/backups";
pub(crate) const ORIGIN_SIDECAR: &str = ".origin";
pub(crate) const ORIGIN_SIDECAR_BYTES: &[u8] = b"backup-session\n";
const PRUNE_DAYS: u64 = 7;
pub fn is_under_backup_dir(path: &Path) -> bool {
let needle: Vec<_> = Path::new(BACKUP_DIR)
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_os_string()),
_ => None,
})
.collect();
if needle.is_empty() {
return false;
}
let mut norm: Vec<std::ffi::OsString> = Vec::new();
for c in path.components() {
match c {
Component::Prefix(_) | Component::RootDir => {}
Component::CurDir => {}
Component::ParentDir => {
let _ = norm.pop();
}
Component::Normal(s) => norm.push(s.to_os_string()),
}
}
norm.windows(needle.len()).any(|w| {
w.iter()
.zip(needle.iter())
.all(|(got, want)| path_component_eq_ignore_ascii_case(got, want))
})
}
fn path_component_eq_ignore_ascii_case(a: &OsStr, b: &OsStr) -> bool {
match (a.to_str(), b.to_str()) {
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
_ => a == b,
}
}
pub fn refuse_user_write_under_backup_dir(path: &Path) -> anyhow::Result<()> {
if is_under_backup_dir(path) {
return Err(crate::exit::InvalidInputError {
msg: format!("refusing write under {BACKUP_DIR}: {}", path.display()),
}
.into());
}
Ok(())
}
pub(crate) fn refuse_declared_paths_under_backup_dir(
cwd: &Path,
op: &crate::plan::Operation,
) -> anyhow::Result<()> {
for p in op.declared_paths() {
let joined = if Path::new(&p).is_absolute() {
PathBuf::from(&p)
} else {
cwd.join(&p)
};
refuse_user_write_under_backup_dir(&joined)?;
}
Ok(())
}
#[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, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub timestamp: String,
pub entries: Vec<ManifestEntry>,
}
pub(crate) fn sanitize_rel_path(file_path: &Path, project_root: &Path) -> PathBuf {
let file_path = dunce::simplified(file_path);
let project_root = dunce::simplified(project_root);
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}"))
}
}
static SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn new_session_id() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let seq = SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{}_{}_{}", now.as_nanos(), std::process::id(), seq)
}
fn parse_session_id_parts(name: &str) -> (u128, u32, u64) {
let mut parts = name.split('_');
let nanos = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mid = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
match parts.next().and_then(|s| s.parse::<u64>().ok()) {
Some(seq) => (nanos, mid.min(u32::MAX as u64) as u32, seq),
None => (nanos, 0, mid),
}
}
pub(crate) fn session_recency_key(
session_dir: &Path,
timestamp: &str,
) -> (u128, std::time::SystemTime, u64) {
let (nanos, _pid, seq) = parse_session_id_parts(timestamp);
let mtime = std::fs::metadata(session_dir)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
(nanos, mtime, seq)
}
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 timestamp = new_session_id();
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()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&session_dir, std::fs::Permissions::from_mode(0o700));
}
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)?;
}
if crate::ops::file::is_regular_file_for_backup(file_path) {
std::fs::copy(file_path, &backup_path).with_context(|| {
format!(
"failed to back up {} to {}",
file_path.display(),
backup_path.display()
)
})?;
} else {
std::fs::write(&backup_path, b"").with_context(|| {
format!(
"writing empty backup marker for {} (special node)",
file_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)
.with_context(|| format!("creating backup dir for {rel_str}"))?;
}
if crate::ops::file::is_regular_file_for_backup(file_path) {
std::fs::copy(file_path, &backup_path)
.with_context(|| format!("backing up {rel_str} before delete"))?;
} else {
std::fs::write(&backup_path, b"")
.with_context(|| format!("writing empty backup marker for {rel_str}"))?;
}
}
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()))?;
let origin_path = self.session_dir.join(ORIGIN_SIDECAR);
std::fs::write(&origin_path, ORIGIN_SIDECAR_BYTES)
.with_context(|| format!("failed to write session origin {}", origin_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)?;
}
let backup_ts = session.finalize()?;
let write_result: anyhow::Result<()> = (|| {
for &(path, content, policy) in files {
crate::write::atomic_write(path, content, policy)?;
}
Ok(())
})();
if let Err(e) = write_result {
let Some(ts) = backup_ts else {
return Err(e);
};
let mutation_msg = e.to_string();
return match restore_session(cwd, &ts) {
Ok(_) => Err(crate::exit::MutationAfterBackupError::restored(ts, mutation_msg).into()),
Err(restore_err) => Err(crate::exit::MutationAfterBackupError::restore_failed(
ts,
restore_err.to_string(),
mutation_msg,
)
.into()),
};
}
Ok(())
}
pub fn find_backup_roots(path: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut current = if path.is_dir() {
path.to_path_buf()
} else {
path.parent().unwrap_or(path).to_path_buf()
};
loop {
let backup_dir = current.join(BACKUP_DIR);
if backup_dir.is_dir() {
roots.push(current.clone());
}
if !current.pop() {
break;
}
}
roots
}
pub fn list_sessions(project_root: &Path) -> anyhow::Result<Vec<Manifest>> {
let (sessions, _warnings) = collect_listed_sessions(project_root)?;
Ok(sessions)
}
fn missing_manifest_warning(session_dir: &Path) -> String {
format!(
"warning: backup session {} has no manifest.json",
session_dir.display()
)
}
fn unreadable_manifest_warning(manifest_path: &Path, err: &impl std::fmt::Display) -> String {
format!(
"warning: unreadable backup manifest {}: {err}",
manifest_path.display()
)
}
fn corrupted_manifest_warning(manifest_path: &Path, err: &impl std::fmt::Display) -> String {
format!(
"warning: corrupted backup manifest {}: {err}",
manifest_path.display()
)
}
fn collect_listed_sessions(project_root: &Path) -> anyhow::Result<(Vec<Manifest>, Vec<String>)> {
let backup_dir = project_root.join(BACKUP_DIR);
if !backup_dir.exists() {
return Ok((Vec::new(), Vec::new()));
}
let mut warnings = Vec::new();
let mut dropped_dirents = 0usize;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)?
.filter_map(|e| match e {
Ok(ent) => Some(ent),
Err(_) => {
dropped_dirents += 1;
None
}
})
.filter(|e| e.path().is_dir())
.collect();
if dropped_dirents > 0 {
warnings.push(format!(
"warning: skipped {dropped_dirents} unreadable backup dirent(s) under {}",
backup_dir.display()
));
}
entries.sort_by(|a, b| {
let ka = session_recency_key(&a.path(), &a.file_name().to_string_lossy());
let kb = session_recency_key(&b.path(), &b.file_name().to_string_lossy());
kb.cmp(&ka)
});
let mut sessions = Vec::new();
for entry in entries {
let session_dir = entry.path();
let manifest_path = session_dir.join("manifest.json");
if !manifest_path.exists() {
warnings.push(missing_manifest_warning(&session_dir));
continue;
}
let content = match std::fs::read_to_string(&manifest_path) {
Ok(content) => content,
Err(e) => {
warnings.push(unreadable_manifest_warning(&manifest_path, &e));
continue;
}
};
match serde_json::from_str::<Manifest>(&content) {
Ok(manifest) => sessions.push(manifest),
Err(e) => warnings.push(corrupted_manifest_warning(&manifest_path, &e)),
}
}
Ok((sessions, warnings))
}
#[derive(Debug, Clone)]
pub struct ListSessionsOptions {
pub ancestors: bool,
pub descendants: bool,
pub max_depth: Option<usize>,
}
impl Default for ListSessionsOptions {
fn default() -> Self {
Self {
ancestors: false,
descendants: true,
max_depth: Some(8),
}
}
}
#[derive(Debug, Clone)]
pub struct SessionListing {
pub project_root: PathBuf,
pub sessions: Vec<Manifest>,
pub warnings: Vec<String>,
}
pub fn list_sessions_under(
project_root: &Path,
opts: &ListSessionsOptions,
) -> anyhow::Result<Vec<SessionListing>> {
let mut roots: Vec<PathBuf> = Vec::new();
roots.push(project_root.to_path_buf());
if opts.ancestors {
let ancestor_cap = opts.max_depth.unwrap_or(8).max(1);
let mut cur = project_root.parent();
let mut walked = 0usize;
while let Some(p) = cur {
if walked >= ancestor_cap {
break;
}
roots.push(p.to_path_buf());
walked += 1;
cur = p.parent();
}
}
if opts.descendants {
let max_depth = opts.max_depth.unwrap_or(8);
collect_descendant_backup_roots(project_root, max_depth, 0, &mut roots)?;
}
let mut seen = std::collections::HashSet::new();
let mut unique_roots = Vec::new();
for r in roots {
if seen.insert(r.clone()) {
unique_roots.push(r);
}
}
let mut out = Vec::new();
for root in unique_roots {
let (sessions, warnings) = collect_listed_sessions(&root)?;
if !sessions.is_empty() || !warnings.is_empty() {
out.push(SessionListing {
project_root: root,
sessions,
warnings,
});
}
}
out.sort_by(|a, b| {
let ka = a.sessions.first().map(|s| {
session_recency_key(
&a.project_root.join(BACKUP_DIR).join(&s.timestamp),
&s.timestamp,
)
});
let kb = b.sessions.first().map(|s| {
session_recency_key(
&b.project_root.join(BACKUP_DIR).join(&s.timestamp),
&s.timestamp,
)
});
kb.cmp(&ka)
});
Ok(out)
}
fn collect_descendant_backup_roots(
dir: &Path,
max_depth: usize,
depth: usize,
out: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
if depth >= max_depth {
return Ok(());
}
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if matches!(
name.as_ref(),
".git" | "target" | "node_modules" | ".patchloom" | "dist" | "build" | ".venv"
) {
continue;
}
let backup = path.join(BACKUP_DIR);
if backup.is_dir() {
out.push(path.clone());
}
collect_descendant_backup_roots(&path, max_depth, depth + 1, out)?;
}
Ok(())
}
pub fn restore_path_from_latest_backup(project_root: &Path, path: &Path) -> anyhow::Result<bool> {
let sessions = list_sessions(project_root)?;
let rel = sanitize_rel_path(path, project_root);
let rel_str = rel.to_string_lossy();
let abs_str = path.to_string_lossy();
for manifest in &sessions {
let hit = manifest
.entries
.iter()
.any(|e| e.path == rel_str || e.path == abs_str);
if hit {
return restore_path_from_session(project_root, &manifest.timestamp, path);
}
}
Ok(false)
}
pub fn restore_path_from_session(
project_root: &Path,
session_timestamp: &str,
path: &Path,
) -> anyhow::Result<bool> {
restore_path_from_session_with_guard(project_root, session_timestamp, path, None)
}
pub fn restore_path_from_session_with_guard(
project_root: &Path,
session_timestamp: &str,
path: &Path,
guard: Option<&PathGuard>,
) -> anyhow::Result<bool> {
let session_dir = project_root.join(BACKUP_DIR).join(session_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 {session_timestamp} (use `patchloom undo --list` to see available sessions)"
)
})?;
let manifest: Manifest = serde_json::from_str(&content)
.with_context(|| format!("parsing backup manifest for session {session_timestamp}"))?;
let rel = sanitize_rel_path(path, project_root);
let rel_str = rel.to_string_lossy();
let abs_str = path.to_string_lossy();
let Some(entry) = manifest
.entries
.iter()
.find(|e| e.path == rel_str || e.path == abs_str)
else {
return Ok(false);
};
check_restore_policy(project_root, &session_dir, &entry.path, guard)?;
let target = resolve_restore_path(project_root, &entry.path);
match entry.action {
FileAction::Modified => {
let backup = session_dir.join(&entry.path);
if !backup.exists() {
return Err(crate::exit::InvalidInputError {
msg: format!(
"backup session {session_timestamp} is incomplete for {}; modified backup blob missing",
entry.path
),
}
.into());
}
ensure_restore_parent_dir(&target, &entry.path)?;
refuse_restore_onto_non_regular(&target, &entry.path)?;
std::fs::copy(&backup, &target)
.with_context(|| format!("restoring modified file {}", entry.path))?;
Ok(true)
}
FileAction::Created => {
refuse_restore_onto_non_regular(&target, &entry.path)?;
if target.exists() {
std::fs::remove_file(&target)
.with_context(|| format!("removing created file {} during undo", entry.path))?;
Ok(true)
} else {
Ok(false)
}
}
FileAction::Deleted => {
let backup = session_dir.join(&entry.path);
if !backup.exists() {
return Err(crate::exit::InvalidInputError {
msg: format!(
"backup session {session_timestamp} is incomplete for {}; deleted backup blob missing",
entry.path
),
}
.into());
}
ensure_restore_parent_dir(&target, &entry.path)?;
refuse_restore_onto_non_regular(&target, &entry.path)?;
std::fs::copy(&backup, &target)
.with_context(|| format!("restoring deleted file {}", entry.path))?;
Ok(true)
}
}
}
pub fn restore_session(project_root: &Path, timestamp: &str) -> anyhow::Result<usize> {
restore_session_with_guard(project_root, timestamp, None)
}
pub fn restore_session_with_guard(
project_root: &Path,
timestamp: &str,
guard: Option<&PathGuard>,
) -> 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} (use `patchloom undo --list` to see available sessions)"))?;
let manifest: Manifest = serde_json::from_str(&content)
.with_context(|| format!("parsing backup manifest for session {timestamp}"))?;
let mut missing: Vec<String> = Vec::new();
for entry in &manifest.entries {
check_restore_policy(project_root, &session_dir, &entry.path, guard)?;
match entry.action {
FileAction::Modified | FileAction::Deleted => {
let backup = session_dir.join(&entry.path);
if !backup.exists() {
let kind = match entry.action {
FileAction::Modified => "modified",
FileAction::Deleted => "deleted",
FileAction::Created => unreachable!(),
};
missing.push(format!("{} ({kind} backup blob missing)", entry.path));
}
}
FileAction::Created => {}
}
}
if !missing.is_empty() {
return Err(crate::exit::InvalidInputError {
msg: format!(
"backup session {timestamp} is incomplete; not removing session. Missing: {}",
missing.join("; ")
),
}
.into());
}
classify_restore_write_dests(project_root, &manifest)?;
let mut restored = 0;
for entry in &manifest.entries {
let target = resolve_restore_path(project_root, &entry.path);
match entry.action {
FileAction::Modified => {
let backup = session_dir.join(&entry.path);
ensure_restore_parent_dir(&target, &entry.path)?;
refuse_restore_onto_non_regular(&target, &entry.path)?;
std::fs::copy(&backup, &target)
.with_context(|| format!("restoring modified file {}", entry.path))?;
restored += 1;
}
FileAction::Created => {
refuse_restore_onto_non_regular(&target, &entry.path)?;
if target.exists() {
std::fs::remove_file(&target).with_context(|| {
format!("removing created file {} during undo", entry.path)
})?;
restored += 1;
}
}
FileAction::Deleted => {
let backup = session_dir.join(&entry.path);
ensure_restore_parent_dir(&target, &entry.path)?;
refuse_restore_onto_non_regular(&target, &entry.path)?;
std::fs::copy(&backup, &target)
.with_context(|| format!("restoring deleted file {}", entry.path))?;
restored += 1;
}
}
}
Ok(restored)
}
pub fn remove_session(project_root: &Path, timestamp: &str) -> anyhow::Result<()> {
let session_dir = project_root.join(BACKUP_DIR).join(timestamp);
if session_dir.is_dir() {
std::fs::remove_dir_all(&session_dir)
.with_context(|| format!("removing consumed backup session {timestamp}"))?;
}
Ok(())
}
fn session_origin_untrusted_reason(session_dir: &Path) -> Option<String> {
let origin = session_dir.join(ORIGIN_SIDECAR);
match std::fs::symlink_metadata(&origin) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Some(format!("missing {ORIGIN_SIDECAR}"))
}
Err(e) => Some(format!("unreadable {ORIGIN_SIDECAR}: {e}")),
Ok(meta) if meta.file_type().is_symlink() => {
Some("not a regular file (symlink)".to_string())
}
Ok(meta) if meta.file_type().is_file() => match std::fs::read(&origin) {
Ok(bytes) if bytes == ORIGIN_SIDECAR_BYTES => None,
Ok(_) => Some(format!("wrong bytes in {ORIGIN_SIDECAR}")),
Err(e) => Some(format!("unreadable {ORIGIN_SIDECAR}: {e}")),
},
Ok(_) => Some("not a regular file".to_string()),
}
}
fn refuse_restore_onto_non_regular(
target: &Path,
entry_path: &str,
) -> Result<(), crate::exit::InvalidInputError> {
use crate::ops::file::{PathEntryKind, classify_path_entry};
match classify_path_entry(target) {
PathEntryKind::Missing | PathEntryKind::RegularFile => Ok(()),
PathEntryKind::RealDirectory | PathEntryKind::Special => {
Err(crate::exit::InvalidInputError {
msg: format!(
"refusing restore onto non-regular destination (symlink or special file): {entry_path}"
),
})
}
}
}
fn is_external_manifest_path(entry_path: &str) -> bool {
if entry_path == "__external__" || entry_path.starts_with("__external__/") {
return true;
}
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("__/")
}
fn check_restore_policy(
project_root: &Path,
session_dir: &Path,
entry_path: &str,
guard: Option<&PathGuard>,
) -> anyhow::Result<()> {
validate_restore_path(entry_path)?;
let external = is_external_manifest_path(entry_path);
if external && let Some(reason) = session_origin_untrusted_reason(session_dir) {
return Err(crate::exit::InvalidInputError {
msg: format!(
"refusing external restore from untrusted session ({reason}): {entry_path}"
),
}
.into());
}
let target = resolve_restore_path(project_root, entry_path);
if let Some(g) = guard {
if external {
return Err(crate::fallback::EditError::guard_rejected(format!(
"contained restore refuses paths outside the project root: {entry_path}"
)));
}
g.check_path(&target.to_string_lossy())
.map_err(crate::fallback::EditError::guard_rejected)?;
}
refuse_restore_onto_non_regular(&target, entry_path)?;
Ok(())
}
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 {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("restore path escapes project root: {entry_path}"),
}));
}
}
std::path::Component::Normal(_) => {
depth += 1;
}
std::path::Component::CurDir => {}
_ => {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("unexpected path component in restore path: {entry_path}"),
}));
}
}
}
Ok(())
}
pub(crate) fn classify_restore_write_dests(
project_root: &Path,
manifest: &Manifest,
) -> Result<(), crate::exit::InvalidInputError> {
for entry in &manifest.entries {
let target = resolve_restore_path(project_root, &entry.path);
refuse_restore_onto_non_regular(&target, &entry.path)?;
}
Ok(())
}
fn ensure_restore_parent_dir(target: &Path, entry_path: &str) -> anyhow::Result<()> {
if let Some(parent) = target.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent dir for restore target {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 now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let max_age = 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()) {
let name = entry.file_name();
let dir_name = name.to_string_lossy();
if let Some(nanos_str) = dir_name.split('_').next()
&& let Ok(nanos) = nanos_str.parse::<u128>()
{
let now_nanos = now.as_nanos();
let age_nanos = now_nanos.saturating_sub(nanos);
let max_age_nanos = max_age.as_nanos();
if age_nanos > max_age_nanos {
let _ = std::fs::remove_dir_all(entry.path());
pruned += 1;
}
}
}
Ok(pruned)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn session_id_includes_pid_and_is_unique() {
let dir = TempDir::new().unwrap();
let a = BackupSession::new(dir.path()).unwrap();
let b = BackupSession::new(dir.path()).unwrap();
assert_ne!(a.timestamp, b.timestamp);
let pid = std::process::id().to_string();
let a_parts: Vec<&str> = a.timestamp.split('_').collect();
let b_parts: Vec<&str> = b.timestamp.split('_').collect();
assert_eq!(a_parts.len(), 3, "expected nanos_pid_seq: {}", a.timestamp);
assert_eq!(a_parts[1], pid);
assert_eq!(b_parts[1], pid);
assert_ne!(a_parts[2], b_parts[2]);
}
#[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");
}
#[cfg(unix)]
#[test]
fn save_before_write_fifo_empty_marker_no_hang() {
use std::process::Command as StdCommand;
use std::time::{Duration, Instant};
let dir = TempDir::new().unwrap();
let fifo = dir.path().join("pipe.fifo");
let status = StdCommand::new("mkfifo")
.arg(&fifo)
.status()
.expect("mkfifo available on unix CI");
assert!(status.success());
let mut session = BackupSession::new(dir.path()).unwrap();
let start = Instant::now();
session
.save_before_write(&fifo)
.expect("FIFO write backup must not hang");
assert!(
start.elapsed() < Duration::from_secs(2),
"save_before_write on FIFO took {:?}",
start.elapsed()
);
let ts = session.finalize().unwrap().unwrap();
let marker = dir
.path()
.join(".patchloom/backups")
.join(&ts)
.join("pipe.fifo");
assert!(marker.exists());
assert_eq!(std::fs::read(&marker).unwrap(), b"");
}
#[test]
fn restore_incomplete_session_errors_and_keeps_session() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("keep.txt");
std::fs::write(&file, "original").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
let backup_blob = dir
.path()
.join(".patchloom/backups")
.join(&ts)
.join("keep.txt");
assert!(backup_blob.exists(), "precondition: backup blob exists");
std::fs::remove_file(&backup_blob).unwrap();
std::fs::write(&file, "mutated").unwrap();
let err = restore_session(dir.path(), &ts).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("incomplete") && msg.contains("keep.txt"),
"expected incomplete-session error, got: {msg}"
);
let sessions = list_sessions(dir.path()).unwrap();
assert!(
sessions.iter().any(|s| s.timestamp == ts),
"incomplete restore must not remove the session"
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "mutated");
}
#[test]
fn list_sessions_missing_manifest_warns_instead_of_silent_skip() {
let dir = TempDir::new().unwrap();
let session_dir = dir.path().join(BACKUP_DIR).join("incomplete-no-manifest");
std::fs::create_dir_all(&session_dir).unwrap();
let (sessions, warnings) = collect_listed_sessions(dir.path()).unwrap();
assert!(
sessions.is_empty(),
"dir without manifest is not a usable session: {sessions:?}"
);
assert_eq!(
warnings,
vec![missing_manifest_warning(&session_dir)],
"list must warn instead of pretending zero sessions"
);
assert_eq!(
warnings[0],
format!(
"warning: backup session {} has no manifest.json",
session_dir.display()
)
);
assert!(list_sessions(dir.path()).unwrap().is_empty());
}
#[test]
fn list_sessions_corrupt_manifest_warns_instead_of_silent_skip() {
let dir = TempDir::new().unwrap();
let session_dir = dir.path().join(BACKUP_DIR).join("corrupt-manifest");
std::fs::create_dir_all(&session_dir).unwrap();
let manifest_path = session_dir.join("manifest.json");
std::fs::write(&manifest_path, "not-json").unwrap();
let (sessions, warnings) = collect_listed_sessions(dir.path()).unwrap();
assert!(sessions.is_empty(), "corrupt JSON is not a usable session");
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].starts_with(&format!(
"warning: corrupted backup manifest {}",
manifest_path.display()
)),
"corrupt list must name the file, got: {}",
warnings[0]
);
}
#[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);
}
fn write_named_session(root: &std::path::Path, ts: &str) {
let d = root.join(BACKUP_DIR).join(ts);
std::fs::create_dir_all(&d).unwrap();
let manifest = Manifest {
timestamp: ts.to_string(),
entries: Vec::new(),
};
std::fs::write(
d.join("manifest.json"),
serde_json::to_string(&manifest).unwrap(),
)
.unwrap();
}
#[test]
fn list_sessions_same_nanos_orders_by_mtime_not_lexicographic_pid() {
let dir = TempDir::new().unwrap();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let older = format!("{n}_99_0");
let newer = format!("{n}_1000_0");
write_named_session(dir.path(), &older);
std::thread::sleep(std::time::Duration::from_millis(20));
write_named_session(dir.path(), &newer);
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(
sessions
.iter()
.map(|s| s.timestamp.as_str())
.collect::<Vec<_>>(),
vec![newer.as_str(), older.as_str()],
"lexicographic Reverse would list _99_ before _1000_"
);
}
#[test]
fn list_sessions_same_nanos_seq_10_is_newer_than_seq_9() {
let dir = TempDir::new().unwrap();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
write_named_session(dir.path(), &format!("{n}_1_9"));
write_named_session(dir.path(), &format!("{n}_1_10"));
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions[0].timestamp, format!("{n}_1_10"));
assert_eq!(sessions[1].timestamp, format!("{n}_1_9"));
}
#[test]
fn parse_session_id_legacy_two_part() {
assert_eq!(parse_session_id_parts("12_3"), (12, 0, 3));
assert_eq!(parse_session_id_parts("12_4_5"), (12, 4, 5));
}
#[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 eight_days_ago = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
- std::time::Duration::from_secs(8 * 24 * 60 * 60);
let old_ts = format!("{}_0", eight_days_ago.as_nanos());
let old_dir = dir.path().join(BACKUP_DIR).join(&old_ts);
std::fs::create_dir_all(&old_dir).unwrap();
std::fs::write(old_dir.join("manifest.json"), "[]").unwrap();
let pruned = prune_old_backups(dir.path()).unwrap();
assert_eq!(pruned, 1);
assert!(!old_dir.exists());
}
#[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 prune_old_backups_handles_large_nanos_without_truncation() {
let dir = TempDir::new().unwrap();
let huge_nanos: u128 = u64::MAX as u128 + 1_000_000_000;
let ts_str = format!("{huge_nanos}_0");
let future_dir = dir.path().join(BACKUP_DIR).join(&ts_str);
std::fs::create_dir_all(&future_dir).unwrap();
std::fs::write(future_dir.join("manifest.json"), "[]").unwrap();
let pruned = prune_old_backups(dir.path()).unwrap();
assert_eq!(
pruned, 0,
"future session with u128 timestamp should not be pruned"
);
assert!(
future_dir.exists(),
"directory should still exist after prune"
);
}
#[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_auto_restores_on_partial_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);
let err = result.expect_err("write to missing dir should fail");
let session = crate::exit::backup_session_from_error(&err)
.expect("fail-restore must peel session without Display scrape");
assert!(
!session.is_empty(),
"session id must be non-empty after finalize"
);
assert_eq!(
std::fs::read_to_string(&real).unwrap(),
"original",
"auto-restore should revert partial writes"
);
}
#[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);
let err = result.expect_err("write to missing dir should fail");
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 1, "backup session must be finalized");
let session = crate::exit::backup_session_from_error(&err).expect("peel session");
assert_eq!(session, sessions[0].timestamp);
assert_eq!(std::fs::read_to_string(&real).unwrap(), "original");
}
#[test]
fn remove_session_allows_sequential_undo() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("seq.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::fs::write(&file, "v2").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s2 = BackupSession::new(dir.path()).unwrap();
s2.save_before_write(&file).unwrap();
let ts2 = s2.finalize().unwrap().unwrap();
std::fs::write(&file, "v3").unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 2);
let latest = &sessions[0].timestamp;
assert_eq!(latest, &ts2);
restore_session(dir.path(), latest).unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "v2");
remove_session(dir.path(), latest).unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 1, "consumed session should be removed");
let latest = &sessions[0].timestamp;
assert_eq!(latest, &ts1);
restore_session(dir.path(), latest).unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"v1",
"sequential undo should reach the original content"
);
}
#[test]
fn restore_path_from_latest_backup_restores_bytes() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("data.txt");
std::fs::write(&file, "before").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
session.finalize().unwrap();
std::fs::write(&file, "after").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "after");
let ok = restore_path_from_latest_backup(dir.path(), &file).unwrap();
assert!(ok);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "before");
}
#[test]
fn restore_path_from_latest_backup_ignores_junk_session_dir() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("data.txt");
std::fs::write(&file, "before").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
session.finalize().unwrap();
std::fs::create_dir_all(dir.path().join(BACKUP_DIR).join("bad-session")).unwrap();
std::fs::write(&file, "after").unwrap();
let ok = restore_path_from_latest_backup(dir.path(), &file).unwrap();
assert!(ok);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "before");
}
#[test]
fn restore_path_from_latest_backup_missing_returns_false() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("never_backed_up.txt");
std::fs::write(&file, "x").unwrap();
let ok = restore_path_from_latest_backup(dir.path(), &file).unwrap();
assert!(!ok);
}
#[test]
fn restore_path_from_session_only_one_file() {
let dir = tempfile::TempDir::new().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
std::fs::write(&a, "A").unwrap();
std::fs::write(&b, "B").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&a).unwrap();
session.save_before_write(&b).unwrap();
session.finalize().unwrap();
std::fs::write(&a, "A2").unwrap();
std::fs::write(&b, "B2").unwrap();
let sessions = list_sessions(dir.path()).unwrap();
let ts = &sessions[0].timestamp;
assert!(restore_path_from_session(dir.path(), ts, &a).unwrap());
assert_eq!(std::fs::read_to_string(&a).unwrap(), "A");
assert_eq!(std::fs::read_to_string(&b).unwrap(), "B2");
}
#[test]
fn restore_path_does_not_match_on_basename_alone() {
let dir = tempfile::TempDir::new().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let file_a = a.join("same.txt");
let file_b = b.join("same.txt");
std::fs::write(&file_a, "A").unwrap();
std::fs::write(&file_b, "B").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file_a).unwrap();
session.finalize().unwrap();
std::fs::write(&file_a, "A2").unwrap();
let ok = restore_path_from_latest_backup(dir.path(), &file_b).unwrap();
assert!(!ok, "basename-only match would restore the wrong path");
assert_eq!(std::fs::read_to_string(&file_a).unwrap(), "A2");
assert_eq!(std::fs::read_to_string(&file_b).unwrap(), "B");
}
#[test]
fn find_backup_roots_walks_parents_nearest_first() {
let outer = tempfile::TempDir::new().unwrap();
let nested = outer.path().join("crates").join("pkg");
std::fs::create_dir_all(&nested).unwrap();
let nested_file = nested.join("src").join("lib.rs");
std::fs::create_dir_all(nested_file.parent().unwrap()).unwrap();
std::fs::write(&nested_file, "fn main() {}").unwrap();
let outer_marker = outer.path().join("outer.txt");
std::fs::write(&outer_marker, "o").unwrap();
let mut s = BackupSession::new(outer.path()).unwrap();
s.save_before_write(&outer_marker).unwrap();
s.finalize().unwrap();
let nested_marker = nested.join("inner.txt");
std::fs::write(&nested_marker, "i").unwrap();
let mut s = BackupSession::new(&nested).unwrap();
s.save_before_write(&nested_marker).unwrap();
s.finalize().unwrap();
let from_file = find_backup_roots(&nested_file);
assert!(
from_file.len() >= 2,
"expected nested + outer roots, got {from_file:?}"
);
assert_eq!(from_file[0], nested, "nearest root first: {from_file:?}");
assert!(
from_file.iter().any(|r| r == outer.path()),
"must include outer root: {from_file:?}"
);
let from_dir = find_backup_roots(&nested);
assert_eq!(from_dir[0], nested);
let empty = tempfile::TempDir::new().unwrap();
let alone = empty.path().join("alone.txt");
std::fs::write(&alone, "x").unwrap();
let none = find_backup_roots(&alone);
assert!(
!none.iter().any(|r| r == empty.path()),
"empty tree must not invent a backup root: {none:?}"
);
let bare = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(bare.path().join(BACKUP_DIR)).unwrap();
let f = bare.path().join("f.txt");
std::fs::write(&f, "x").unwrap();
let roots = find_backup_roots(&f);
assert!(
roots.iter().any(|r| r == bare.path()),
"empty backups dir must still be a root: {roots:?}"
);
}
#[test]
fn list_sessions_under_ancestors_respects_max_depth() {
let deep = tempfile::TempDir::new().unwrap();
let nested = deep.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&nested).unwrap();
let file = deep.path().join("top.txt");
std::fs::write(&file, "x").unwrap();
let mut session = BackupSession::new(deep.path()).unwrap();
session.save_before_write(&file).unwrap();
session.finalize().unwrap();
let found = list_sessions_under(
&nested,
&ListSessionsOptions {
ancestors: true,
descendants: false,
max_depth: Some(2),
},
)
.unwrap();
assert!(
found.is_empty(),
"cap 2 from nested/d must not reach temp root: {found:?}"
);
let found_far = list_sessions_under(
&nested,
&ListSessionsOptions {
ancestors: true,
descendants: false,
max_depth: Some(8),
},
)
.unwrap();
assert!(
found_far.iter().any(|l| l.project_root == deep.path()),
"cap 8 should reach temp root: {found_far:?}"
);
}
#[test]
fn list_sessions_under_nested_and_depth_cap() {
let workspace = tempfile::TempDir::new().unwrap();
let nested = workspace.path().join("crates").join("pkg");
std::fs::create_dir_all(&nested).unwrap();
let file = nested.join("f.txt");
std::fs::write(&file, "x").unwrap();
let mut session = BackupSession::new(&nested).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().expect("session");
assert!(list_sessions(workspace.path()).unwrap().is_empty());
let listings = list_sessions_under(
workspace.path(),
&ListSessionsOptions {
descendants: true,
max_depth: Some(8),
ancestors: false,
},
)
.unwrap();
assert_eq!(listings.len(), 1);
assert_eq!(listings[0].sessions[0].timestamp, ts);
let shallow = list_sessions_under(
workspace.path(),
&ListSessionsOptions {
descendants: true,
max_depth: Some(1),
ancestors: false,
},
)
.unwrap();
assert!(
shallow.is_empty(),
"max_depth=1 should not reach crates/pkg: {shallow:?}"
);
}
#[test]
fn list_sessions_under_missing_manifest_returns_warnings() {
let dir = TempDir::new().unwrap();
let session_dir = dir.path().join(BACKUP_DIR).join("incomplete-no-manifest");
std::fs::create_dir_all(&session_dir).unwrap();
let listings = list_sessions_under(
dir.path(),
&ListSessionsOptions {
descendants: false,
ancestors: false,
max_depth: Some(8),
},
)
.unwrap();
assert_eq!(listings.len(), 1, "warning-only root must still be listed");
assert!(
listings[0].sessions.is_empty(),
"missing manifest is not usable: {:?}",
listings[0].sessions
);
assert_eq!(
listings[0].warnings,
vec![missing_manifest_warning(&session_dir)]
);
assert!(
listings[0].warnings[0].contains("manifest.json"),
"warning must name manifest.json: {:?}",
listings[0].warnings
);
}
#[test]
fn is_under_backup_dir_detects_normalized_paths() {
assert!(is_under_backup_dir(Path::new(
".patchloom/backups/evil/manifest.json"
)));
assert!(is_under_backup_dir(Path::new(
"/proj/.patchloom/backups/id/blob"
)));
assert!(is_under_backup_dir(Path::new(
"foo/../.patchloom/backups/x"
)));
assert!(is_under_backup_dir(Path::new(".patchloom/./backups/x")));
assert!(is_under_backup_dir(Path::new(".patchloom/backups")));
assert!(!is_under_backup_dir(Path::new(".patchloom/other")));
assert!(!is_under_backup_dir(Path::new("src/main.rs")));
assert!(!is_under_backup_dir(Path::new("backups/foo")));
}
#[test]
fn is_under_backup_dir_detects_case_fold() {
assert!(is_under_backup_dir(Path::new(
".PATCHLOOM/backups/x/manifest.json"
)));
assert!(is_under_backup_dir(Path::new(".Patchloom/BACKUPS/x")));
assert!(is_under_backup_dir(Path::new(
"/proj/.patchloom/BACKUPS/id/blob"
)));
assert!(is_under_backup_dir(Path::new(
"foo/../.PATCHLOOM/backups/x"
)));
assert!(!is_under_backup_dir(Path::new(".PATCHLOOM/other")));
assert!(!is_under_backup_dir(Path::new("BACKUPS/foo")));
}
#[test]
fn finalize_writes_origin_sidecar() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "x").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&file).unwrap();
let ts = session.finalize().unwrap().unwrap();
assert!(
dir.path()
.join(BACKUP_DIR)
.join(&ts)
.join(ORIGIN_SIDECAR)
.is_file(),
"BackupSession must write {ORIGIN_SIDECAR}"
);
}
#[test]
fn file_create_refuses_backup_dir_write() {
let dir = TempDir::new().unwrap();
let target = dir
.path()
.join(BACKUP_DIR)
.join("evil")
.join("manifest.json");
let err = crate::api::file_create(
&target,
"{\"forged\":true}\n",
false,
crate::api::ApplyMode::Apply,
None,
)
.unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"expected invalid_input, got: {err:#}"
);
assert!(!target.exists(), "forged backup manifest must not exist");
}
#[test]
fn file_create_refuses_case_fold_backup_dir_write() {
let dir = TempDir::new().unwrap();
let target = dir
.path()
.join(".PATCHLOOM")
.join("backups")
.join("x")
.join("manifest.json");
let err = crate::api::file_create(
&target,
"{\"forged\":true}\n",
false,
crate::api::ApplyMode::Apply,
None,
)
.unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"expected invalid_input, got: {err:#}"
);
assert!(
!target.exists(),
"forged case-fold backup manifest must not exist"
);
}
#[test]
fn writers_refuse_backup_dir_targets() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join(BACKUP_DIR).join("evil").join("x.txt");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
std::fs::write(&dest, "old").unwrap();
let err = crate::api::replace_text(
&dest,
"old",
"new",
&crate::api::ReplaceOptions::default(),
crate::api::ApplyMode::Apply,
None,
)
.unwrap_err();
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "old");
let err =
crate::api::file_append(&dest, "more", crate::api::ApplyMode::Apply, None).unwrap_err();
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "old");
let err =
crate::api::file_prepend(&dest, "pre", crate::api::ApplyMode::Apply, None).unwrap_err();
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "old");
let src = dir.path().join("src.txt");
std::fs::write(&src, "moved").unwrap();
let err = crate::api::file_rename(&src, &dest, true, crate::api::ApplyMode::Apply, None)
.unwrap_err();
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "old");
assert_eq!(std::fs::read_to_string(&src).unwrap(), "moved");
}
fn write_forged_session(
project: &Path,
ts: &str,
entry_path: &str,
action: FileAction,
blob: Option<&[u8]>,
with_origin: bool,
) {
let session_dir = project.join(BACKUP_DIR).join(ts);
std::fs::create_dir_all(&session_dir).unwrap();
if let Some(bytes) = blob {
let blob_path = session_dir.join(entry_path);
if let Some(parent) = blob_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(blob_path, bytes).unwrap();
}
let manifest = Manifest {
timestamp: ts.to_string(),
entries: vec![ManifestEntry {
path: entry_path.to_string(),
action,
}],
};
std::fs::write(
session_dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
if with_origin {
std::fs::write(session_dir.join(ORIGIN_SIDECAR), ORIGIN_SIDECAR_BYTES).unwrap();
}
}
#[test]
fn restore_contain_refuses_forged_external() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-undo-target");
std::fs::write(&outside_file, "keep me").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
assert!(
ext_path.starts_with("__external"),
"expected external prefix, got {ext_path}"
);
let ts = "forged-contain";
write_forged_session(
dir.path(),
ts,
&ext_path,
FileAction::Modified,
Some(b"pwned"),
true,
);
let guard = PathGuard::new(
dir.path().to_path_buf(),
crate::containment::AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let err = restore_session_with_guard(dir.path(), ts, Some(&guard)).unwrap_err();
assert!(
crate::api::is_guard_rejected(&err) || crate::exit::is_invalid_input(&err),
"contained restore must fail, got: {err:#}"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep me");
}
#[test]
fn restore_contain_refuses_created_external_delete() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-created-target");
std::fs::write(&outside_file, "do not delete").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
let ts = "forged-created";
write_forged_session(dir.path(), ts, &ext_path, FileAction::Created, None, true);
let guard = PathGuard::new(
dir.path().to_path_buf(),
crate::containment::AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let err = restore_session_with_guard(dir.path(), ts, Some(&guard)).unwrap_err();
assert!(
crate::api::is_guard_rejected(&err) || crate::exit::is_invalid_input(&err),
"contained restore must fail, got: {err:#}"
);
assert!(
outside_file.exists(),
"Created + __external__ must not delete under contain"
);
assert_eq!(
std::fs::read_to_string(&outside_file).unwrap(),
"do not delete"
);
}
#[test]
fn restore_forged_external_without_origin_refused() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-untrusted");
std::fs::write(&outside_file, "keep").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
let ts = "forged-no-origin";
write_forged_session(
dir.path(),
ts,
&ext_path,
FileAction::Modified,
Some(b"pwned"),
false,
);
let err = restore_session(dir.path(), ts).unwrap_err();
let msg = err.to_string();
assert!(
crate::exit::is_invalid_input(&err),
"untrusted external restore must be invalid_input, got: {err:#}"
);
assert!(
msg.contains("missing .origin"),
"missing sidecar must name that case, got: {msg}"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
}
#[cfg(unix)]
#[test]
fn restore_forged_external_symlink_origin_refused() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-symlink-origin");
std::fs::write(&outside_file, "keep").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
let ts = "forged-symlink-origin";
write_forged_session(
dir.path(),
ts,
&ext_path,
FileAction::Modified,
Some(b"pwned"),
false,
);
let decoy = dir.path().join("README");
std::fs::write(&decoy, ORIGIN_SIDECAR_BYTES).unwrap();
let origin = dir.path().join(BACKUP_DIR).join(ts).join(ORIGIN_SIDECAR);
std::os::unix::fs::symlink(&decoy, &origin).unwrap();
let err = restore_session(dir.path(), ts).unwrap_err();
let msg = err.to_string();
assert!(
crate::exit::is_invalid_input(&err),
"symlink .origin must not trust external restore, got: {err:#}"
);
assert!(
msg.contains("symlink") && msg.contains("not a regular file"),
"symlink .origin must name symlink / not a regular file, got: {msg}"
);
assert!(
!msg.contains("missing"),
"symlink .origin must not look like a missing sidecar, got: {msg}"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
}
#[test]
fn restore_forged_external_wrong_origin_bytes_refused() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-wrong-origin");
std::fs::write(&outside_file, "keep").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
let ts = "forged-wrong-origin";
write_forged_session(
dir.path(),
ts,
&ext_path,
FileAction::Modified,
Some(b"pwned"),
false,
);
std::fs::write(
dir.path().join(BACKUP_DIR).join(ts).join(ORIGIN_SIDECAR),
b"not-a-backup-session\n",
)
.unwrap();
let err = restore_session(dir.path(), ts).unwrap_err();
let msg = err.to_string();
assert!(
crate::exit::is_invalid_input(&err),
"wrong .origin bytes must not trust external restore, got: {err:#}"
);
assert!(
msg.contains("wrong bytes"),
"wrong .origin bytes must name that case, got: {msg}"
);
assert!(
!msg.contains("missing"),
"wrong bytes must not look like a missing sidecar, got: {msg}"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
}
#[cfg(unix)]
#[test]
fn restore_forged_external_unreadable_origin_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("forged-unreadable-origin");
std::fs::write(&outside_file, "keep").unwrap();
let ext_path = sanitize_rel_path(&outside_file, dir.path())
.to_string_lossy()
.into_owned();
let ts = "forged-unreadable-origin";
write_forged_session(
dir.path(),
ts,
&ext_path,
FileAction::Modified,
Some(b"pwned"),
false,
);
let origin = dir.path().join(BACKUP_DIR).join(ts).join(ORIGIN_SIDECAR);
std::fs::write(&origin, ORIGIN_SIDECAR_BYTES).unwrap();
std::fs::set_permissions(&origin, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&origin).is_ok() {
std::fs::set_permissions(&origin, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let err = restore_session(dir.path(), ts).unwrap_err();
let msg = err.to_string();
let _ = std::fs::set_permissions(&origin, std::fs::Permissions::from_mode(0o644));
assert!(
crate::exit::is_invalid_input(&err),
"unreadable .origin must be invalid_input, got: {err:#}"
);
assert!(
msg.contains("unreadable"),
"unreadable .origin must name that case, got: {msg}"
);
assert!(
!msg.contains("missing"),
"unreadable .origin must not look like a missing sidecar, got: {msg}"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
}
#[cfg(unix)]
#[test]
fn restore_refuses_dest_symlink_leaves_target() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join("app.toml");
std::fs::write(&dest, "original dest").unwrap();
let mut session = BackupSession::new(dir.path()).unwrap();
session.save_before_write(&dest).unwrap();
let ts = session.finalize().unwrap().unwrap();
std::fs::write(&dest, "modified dest").unwrap();
std::fs::remove_file(&dest).unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("secret");
std::fs::write(&outside_file, "do not overwrite").unwrap();
std::os::unix::fs::symlink(&outside_file, &dest).unwrap();
let err = restore_session(dir.path(), &ts).unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"restore onto dest symlink must be invalid_input, got: {err:#}"
);
assert_eq!(
std::fs::read_to_string(&outside_file).unwrap(),
"do not overwrite"
);
assert!(
dest.symlink_metadata().unwrap().file_type().is_symlink(),
"dest entry must remain a symlink"
);
}
}