use std::path::Path;
use crate::fs::atomic::open_dir_nofollow;
use rustix::fs::{fchmod, openat, AtFlags, Mode, OFlags};
use std::fs;
use std::os::unix;
use std::os::unix::fs::PermissionsExt as _;
use super::sidecar::{write_sidecar, BackupSidecar};
#[must_use]
pub fn backup_path_with_tag(target: &Path, tag: &str) -> std::path::PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let name = target
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("backup");
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
parent.join(format!(".{name}.{tag}.{ts}.bak"))
}
#[allow(
clippy::too_many_lines,
reason = "Snapshot creation handles multiple target states (file/symlink/absent) and TOCTOU-safe flows; splitting would hurt clarity and atomicity"
)]
pub fn create_snapshot(target: &Path, backup_tag: &str) -> std::io::Result<()> {
let metadata = fs::symlink_metadata(target);
let existed = metadata.is_ok();
let is_symlink = metadata
.as_ref()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_symlink {
let current_dest = fs::read_link(target).ok();
if let Some(curr) = current_dest.as_ref() {
let mut backup = backup_path_with_tag(target, backup_tag);
while backup.exists() {
if let Some(stem) = backup.file_name().and_then(|s| s.to_str()) {
if let Some(prefix) = stem.strip_suffix(".bak") {
if let Some((pre, ts_s)) = prefix.rsplit_once('.') {
if let Ok(ts) = ts_s.parse::<u128>() {
let bumped = format!("{pre}.{}.bak", ts.saturating_add(1));
backup = backup
.parent()
.unwrap_or_else(|| Path::new("."))
.join(bumped);
continue;
}
}
}
}
break;
}
let _ = fs::remove_file(&backup);
let _ = unix::fs::symlink(curr, &backup);
let sc = BackupSidecar {
schema: "backup_meta.v1".to_string(),
prior_kind: "symlink".to_string(),
prior_dest: Some(curr.display().to_string()),
mode: None,
payload_hash: None,
};
write_sidecar(&backup, &sc)
.map_err(|e| std::io::Error::other(format!("sidecar write failed: {e}")))?;
let _ = crate::fs::atomic::fsync_parent_dir(target);
}
return Ok(());
}
if existed {
if let Ok(ref meta) = metadata {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let dirfd = open_dir_nofollow(parent)?;
let fname = target
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("target");
let mut backup_pb = backup_path_with_tag(target, backup_tag);
while backup_pb.exists() {
if let Some(stem) = backup_pb.file_name().and_then(|s| s.to_str()) {
if let Some(prefix) = stem.strip_suffix(".bak") {
if let Some((pre, ts_s)) = prefix.rsplit_once('.') {
if let Ok(ts) = ts_s.parse::<u128>() {
let bumped = format!("{pre}.{}.bak", ts.saturating_add(1));
backup_pb = backup_pb
.parent()
.unwrap_or_else(|| Path::new("."))
.join(bumped);
continue;
}
}
}
}
break;
}
let bname = backup_pb
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("backup");
let fname_c = std::ffi::CString::new(fname).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid cstring")
})?;
let bname_c = std::ffi::CString::new(bname).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid cstring")
})?;
let _ = rustix::fs::unlinkat(&dirfd, bname_c.as_c_str(), AtFlags::empty());
let srcfd = openat(&dirfd, fname_c.as_c_str(), OFlags::RDONLY, Mode::empty())
.map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error()))?;
let dstfd = openat(
&dirfd,
bname_c.as_c_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC,
Mode::from_bits_truncate(0o600),
)
.map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error()))?;
let mut sfile = fs::File::from(srcfd);
let mut dfile = fs::File::from(dstfd);
std::io::copy(&mut sfile, &mut dfile)?;
let mode = meta.permissions().mode();
let dstfd2 = openat(&dirfd, bname_c.as_c_str(), OFlags::RDONLY, Mode::empty())
.map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error()))?;
fchmod(&dstfd2, Mode::from_bits_truncate(mode))
.map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error()))?;
let payload_hash = crate::fs::meta::sha256_hex_of(&backup_pb);
let _ = dfile.sync_all();
let sc = BackupSidecar {
schema: if payload_hash.is_some() {
"backup_meta.v2".to_string()
} else {
"backup_meta.v1".to_string()
},
prior_kind: "file".to_string(),
prior_dest: None,
mode: Some(format!("{mode:o}")),
payload_hash,
};
write_sidecar(&backup_pb, &sc)
.map_err(|e| std::io::Error::other(format!("sidecar write failed: {e}")))?;
let _ = crate::fs::atomic::fsync_parent_dir(target);
return Ok(());
}
}
let mut backup = backup_path_with_tag(target, backup_tag);
while backup.exists() {
if let Some(stem) = backup.file_name().and_then(|s| s.to_str()) {
if let Some(prefix) = stem.strip_suffix(".bak") {
if let Some((pre, ts_s)) = prefix.rsplit_once('.') {
if let Ok(ts) = ts_s.parse::<u128>() {
let bumped = format!("{pre}.{}.bak", ts.saturating_add(1));
backup = backup
.parent()
.unwrap_or_else(|| Path::new("."))
.join(bumped);
continue;
}
}
}
}
break;
}
let _ = fs::remove_file(&backup);
let f = fs::File::create(&backup)?;
let _ = f.sync_all();
let sc = BackupSidecar {
schema: "backup_meta.v1".to_string(),
prior_kind: "none".to_string(),
prior_dest: None,
mode: None,
payload_hash: None,
};
write_sidecar(&backup, &sc)
.map_err(|e| std::io::Error::other(format!("sidecar write failed: {e}")))?;
let _ = crate::fs::atomic::fsync_parent_dir(target);
Ok(())
}
#[must_use]
pub fn has_backup_artifacts(target: &Path, tag: &str) -> bool {
if let Some((payload, sc)) = super::index::find_latest_backup_and_sidecar(target, tag) {
payload.is_some() || sc.exists()
} else {
false
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::constants::DEFAULT_BACKUP_TAG;
use crate::fs::backup::index;
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn snapshot_file_creates_payload_and_sidecar() {
let t = tmp();
let root = t.path();
let tgt = root.join("file.txt");
fs::write(&tgt, b"hello").unwrap();
create_snapshot(&tgt, DEFAULT_BACKUP_TAG).unwrap();
let pair = index::find_latest_backup_and_sidecar(&tgt, DEFAULT_BACKUP_TAG).expect("pair");
assert!(pair.0.is_some(), "payload present");
assert!(pair.1.exists(), "sidecar exists");
}
#[test]
fn snapshot_symlink_creates_symlink_backup_and_sidecar() {
let t = tmp();
let root = t.path();
let target = root.join("bin");
fs::create_dir_all(&target).unwrap();
let link = root.join("usr/bin/app");
fs::create_dir_all(link.parent().unwrap()).unwrap();
let _ = unix::fs::symlink("../../bin", &link); create_snapshot(&link, DEFAULT_BACKUP_TAG).unwrap();
let pair = index::find_latest_backup_and_sidecar(&link, DEFAULT_BACKUP_TAG).expect("pair");
assert!(pair.1.exists(), "sidecar exists");
}
#[test]
fn snapshot_none_creates_tombstone_and_sidecar() {
let t = tmp();
let root = t.path();
let tgt = root.join("missing");
assert!(!tgt.exists());
create_snapshot(&tgt, DEFAULT_BACKUP_TAG).unwrap();
let pair = index::find_latest_backup_and_sidecar(&tgt, DEFAULT_BACKUP_TAG).expect("pair");
assert!(pair.1.exists(), "sidecar exists");
}
}