use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::SystemTime;
use crate::blob::BlobStore;
use crate::error::Result;
use crate::error::SnapshotError;
use crate::manifest::Manifest;
use ignore::gitignore::Gitignore;
pub(crate) fn is_protected(rules: &Gitignore, path: &str) -> bool {
crate::scope::is_ignored(rules, Path::new(path))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteAction {
pub path: String,
pub hash: String,
pub mode: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RestorePlan {
pub writes: Vec<WriteAction>,
pub deletes: Vec<String>,
}
pub fn plan_restore(target: &Manifest, current: &Manifest, rules: &Gitignore) -> RestorePlan {
let mut plan = RestorePlan::default();
for (path, entry) in &target.entries {
if is_protected(rules, path) {
continue;
}
let differs = current.entries.get(path).is_none_or(|cur| {
cur.hash != entry.hash || matches!((cur.mode, entry.mode), (Some(a), Some(b)) if a != b)
});
if differs {
plan.writes.push(WriteAction {
path: path.clone(),
hash: entry.hash.clone(),
mode: entry.mode,
});
}
}
for path in current.entries.keys() {
if !is_protected(rules, path) && target.absent.contains(path) {
plan.deletes.push(path.clone());
}
}
plan
}
#[derive(Debug, Default)]
pub struct ApplyStats {
pub written: usize,
pub deleted: usize,
pub failed: Vec<(PathBuf, SnapshotError)>,
}
pub fn apply_plan(blobs: &BlobStore, plan: &RestorePlan) -> ApplyStats {
let mut stats = ApplyStats::default();
sweep_residue(plan);
for write in &plan.writes {
let path = PathBuf::from(&write.path);
match write_one(blobs, write, &path) {
Ok(()) => stats.written += 1,
Err(err) => stats.failed.push((path, err)),
}
}
for del in &plan.deletes {
let path = PathBuf::from(del);
match fs::remove_file(&path) {
Ok(()) => stats.deleted += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => stats
.failed
.push((path.clone(), SnapshotError::io(&path, e))),
}
}
stats
}
fn write_one(blobs: &BlobStore, write: &WriteAction, path: &Path) -> Result<()> {
let content = blobs.load(&write.hash)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| SnapshotError::io(parent, e))?;
}
let tmp = tmp_path(path);
fs::write(&tmp, &content).map_err(|e| SnapshotError::io(&tmp, e))?;
set_mode(&tmp, write.mode.or_else(|| current_mode(path)))?;
fs::rename(&tmp, path).map_err(|e| SnapshotError::io(path, e))
}
pub const RESTORE_TMP_SUFFIX: &str = ".filesnap-restore-tmp";
fn tmp_path(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(RESTORE_TMP_SUFFIX);
path.with_file_name(name)
}
const RESIDUE_GRACE: Duration = Duration::from_secs(300);
fn sweep_residue(plan: &RestorePlan) {
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
for write in &plan.writes {
let Some(parent) = Path::new(&write.path).parent() else {
continue;
};
if !seen.insert(parent.to_path_buf()) {
continue;
}
for stray in residue_in(parent) {
let _ = fs::remove_file(stray);
}
}
}
pub fn residue_in(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| is_settled_residue(path))
.collect()
}
fn current_mode(path: &Path) -> Option<u32> {
crate::manifest::mode_of(&fs::metadata(path).ok()?)
}
fn set_mode(path: &Path, mode: Option<u32>) -> Result<()> {
let Some(mode) = mode else {
return Ok(());
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.map_err(|e| SnapshotError::io(path, e))?;
}
#[cfg(not(unix))]
{
let _ = (path, mode);
}
Ok(())
}
pub fn residue_under(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.standard_filters(false)
.hidden(false)
.follow_links(false)
.filter_entry(|entry| {
let name = entry.file_name();
name != ".git"
&& !crate::scope::RECENT_SKIP_DIRS
.iter()
.any(|skip| name == *skip)
})
.build();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.into_path();
if is_settled_residue(&path) {
out.push(path);
}
}
out.sort();
out
}
fn is_settled_residue(path: &Path) -> bool {
let cutoff = SystemTime::now()
.checked_sub(RESIDUE_GRACE)
.unwrap_or(SystemTime::UNIX_EPOCH);
path.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with(RESTORE_TMP_SUFFIX))
&& fs::metadata(path)
.and_then(|meta| meta.modified())
.is_ok_and(|written| written <= cutoff)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::manifest::FileEntry;
use pretty_assertions::assert_eq;
fn manifest(entries: &[(&str, &str)]) -> Manifest {
let mut m = Manifest::default();
for (path, hash) in entries {
m.entries.insert(
(*path).to_string(),
FileEntry {
mode: Some(0o644),
size: hash.len() as u64,
mtime_secs: 0,
mtime_nanos: 0,
hash: (*hash).to_string(),
},
);
}
m
}
#[test]
fn writes_files_that_differ_or_are_missing() {
let target = manifest(&[("/a", "h-old"), ("/b", "h-b")]);
let current = manifest(&[("/a", "h-new")]);
let plan = plan_restore(&target, ¤t, &Gitignore::empty());
let paths: Vec<&str> = plan.writes.iter().map(|w| w.path.as_str()).collect();
assert_eq!(paths, vec!["/a", "/b"]);
}
#[test]
fn identical_states_need_no_work() {
let m = manifest(&[("/a", "h")]);
assert_eq!(
plan_restore(&m, &m, &Gitignore::empty()),
RestorePlan::default()
);
}
#[test]
fn deletion_needs_the_target_to_have_looked() {
let target = manifest(&[("/kept", "h-k")]);
let current = manifest(&[("/kept", "h-k"), ("/added", "h-a")]);
let plan = plan_restore(&target, ¤t, &Gitignore::empty());
assert!(
plan.deletes.is_empty(),
"missing from the target says nothing on its own"
);
let mut target = target;
target.absent.insert("/added".to_string());
let plan = plan_restore(&target, ¤t, &Gitignore::empty());
assert_eq!(plan.deletes, vec!["/added"]);
assert!(plan.writes.is_empty());
}
#[test]
fn protected_paths_are_untouched_in_both_directions() {
let target = manifest(&[("/secret/a", "h-1"), ("/ok", "h-ok")]);
let current = manifest(&[("/secret/b", "h-2")]);
let mut builder = crate::scope::GitignoreBuilder::new("/");
builder.add_line(None, "/secret/**").unwrap();
let protect = builder.build().unwrap();
let plan = plan_restore(&target, ¤t, &protect);
let write_paths: Vec<&str> = plan.writes.iter().map(|w| w.path.as_str()).collect();
assert_eq!(
write_paths,
vec!["/ok"],
"protected target entry not restored"
);
assert!(
plan.deletes.is_empty(),
"protected current entry not deleted"
);
}
#[test]
fn restoring_is_idempotent_for_a_given_target() {
let target = manifest(&[("/a", "h-old")]);
let current = manifest(&[("/a", "h-new")]);
let first = plan_restore(&target, ¤t, &Gitignore::empty());
let after_undo = plan_restore(&target, ¤t, &Gitignore::empty());
assert_eq!(first, after_undo);
}
}