use crate::cas::{CasError, ManifestEntry, TreeBlobStore, TreeManifest};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::{fs, io};
#[derive(Debug, thiserror::Error)]
pub enum TreeError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("cas error: {0}")]
Cas(#[from] CasError),
#[error("blob missing for {path}")]
BlobMissing { path: PathBuf },
}
pub fn capture_tree(blobs: &TreeBlobStore, root: &Path) -> Result<TreeManifest, TreeError> {
let mut entries: Vec<ManifestEntry> = Vec::new();
collect_entries(blobs, root, root, &mut entries)?;
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(TreeManifest::new(entries, vec![]))
}
fn collect_entries(
blobs: &TreeBlobStore,
root: &Path,
dir: &Path,
entries: &mut Vec<ManifestEntry>,
) -> Result<(), TreeError> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_entries(blobs, root, &path, entries)?;
} else if file_type.is_file() {
let metadata = fs::metadata(&path)?;
let content = fs::read(&path)?;
let key = blobs.put_file_blob(&content)?;
let mode = (metadata.mode() & 0o7777) as u32;
let mtime_secs = metadata.mtime();
let mtime_nanos = metadata.mtime_nsec() as u32;
let rel = path
.strip_prefix(root)
.expect("path must be under root")
.to_path_buf();
entries.push(ManifestEntry::entry_for(
rel,
key,
mode,
(mtime_secs, mtime_nanos),
));
}
}
Ok(())
}
pub fn restore_tree(
blobs: &TreeBlobStore,
manifest: &TreeManifest,
dest: &Path,
) -> Result<(), TreeError> {
for entry in &manifest.entries {
let target = dest.join(&entry.path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let bytes =
blobs
.get_file_blob(&entry.content_hash)?
.ok_or_else(|| TreeError::BlobMissing {
path: entry.path.clone(),
})?;
fs::write(&target, &bytes)?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&target, fs::Permissions::from_mode(entry.mode))?;
let ft = filetime::FileTime::from_unix_time(entry.mtime_secs, entry.mtime_nanos);
filetime::set_file_mtime(&target, ft)?;
}
for deleted in &manifest.deleted {
let target = dest.join(deleted);
if target.exists() {
fs::remove_file(&target)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cas::TreeBlobStore;
use crate::store::FileStore;
use filetime::FileTime;
use std::path::PathBuf;
use tempfile::TempDir;
fn setup_store() -> (TempDir, FileStore) {
let dir = TempDir::new().unwrap();
let store = FileStore::open(dir.path()).unwrap();
(dir, store)
}
#[test]
fn round_trip_fidelity() {
let (_store_dir, store) = setup_store();
let blobs = TreeBlobStore::new(&store);
let src_dir = TempDir::new().unwrap();
let nested = src_dir.path().join("sub/dir");
fs::create_dir_all(&nested).unwrap();
let file_a = src_dir.path().join("top.txt");
fs::write(&file_a, b"hello top").unwrap();
let file_b = nested.join("deep.bin");
fs::write(&file_b, b"\x00\x01\x02\x03").unwrap();
let known_mtime = FileTime::from_unix_time(1_700_000_000, 123_456_789);
filetime::set_file_mtime(&file_a, known_mtime).unwrap();
use std::os::unix::fs::PermissionsExt;
let mode_a = fs::metadata(&file_a).unwrap().permissions().mode() & 0o7777;
let mode_b = fs::metadata(&file_b).unwrap().permissions().mode() & 0o7777;
let mtime_b = {
use std::os::unix::fs::MetadataExt;
let md = fs::metadata(&file_b).unwrap();
FileTime::from_unix_time(md.mtime(), md.mtime_nsec() as u32)
};
let manifest = capture_tree(&blobs, src_dir.path()).unwrap();
assert_eq!(manifest.entries.len(), 2);
assert!(manifest.deleted.is_empty());
let dest_dir = TempDir::new().unwrap();
restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
let restored_a = fs::read(dest_dir.path().join("top.txt")).unwrap();
assert_eq!(restored_a, b"hello top");
let restored_b = fs::read(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
assert_eq!(restored_b, b"\x00\x01\x02\x03");
let restored_mode_a = fs::metadata(dest_dir.path().join("top.txt"))
.unwrap()
.permissions()
.mode()
& 0o7777;
let restored_mode_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(restored_mode_a, mode_a);
assert_eq!(restored_mode_b, mode_b);
use std::os::unix::fs::MetadataExt;
let md_a = fs::metadata(dest_dir.path().join("top.txt")).unwrap();
assert_eq!(md_a.mtime(), 1_700_000_000);
assert_eq!(md_a.mtime_nsec() as u32, 123_456_789);
let md_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
assert_eq!(
FileTime::from_unix_time(md_b.mtime(), md_b.mtime_nsec() as u32),
mtime_b
);
}
#[test]
fn deletion_removes_file_from_dest() {
let (_store_dir, store) = setup_store();
let blobs = TreeBlobStore::new(&store);
let dest_dir = TempDir::new().unwrap();
let target = dest_dir.path().join("to_delete.txt");
fs::write(&target, b"present").unwrap();
assert!(target.exists());
let manifest = TreeManifest::new(vec![], vec![PathBuf::from("to_delete.txt")]);
restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
assert!(!target.exists(), "deleted path must be removed from dest");
}
#[test]
fn missing_blob_returns_error() {
let (_store_dir, store) = setup_store();
let blobs = TreeBlobStore::new(&store);
let dest_dir = TempDir::new().unwrap();
let phantom_key = crate::store::Key::from_bytes(b"never-stored");
let entry =
ManifestEntry::entry_for(PathBuf::from("ghost.txt"), phantom_key, 0o644, (0, 0));
let manifest = TreeManifest::new(vec![entry], vec![]);
let err = restore_tree(&blobs, &manifest, dest_dir.path())
.expect_err("must error on missing blob");
assert!(
matches!(err, TreeError::BlobMissing { .. }),
"unexpected error variant: {err:?}"
);
}
}