1use crate::cas::{CasError, ManifestEntry, TreeBlobStore, TreeManifest};
2use std::os::unix::fs::MetadataExt;
3use std::path::{Path, PathBuf};
4use std::{fs, io};
5
6#[derive(Debug, thiserror::Error)]
7pub enum TreeError {
8 #[error("io error: {0}")]
9 Io(#[from] io::Error),
10 #[error("cas error: {0}")]
11 Cas(#[from] CasError),
12 #[error("blob missing for {path}")]
13 BlobMissing { path: PathBuf },
14}
15
16pub fn capture_tree(blobs: &TreeBlobStore, root: &Path) -> Result<TreeManifest, TreeError> {
17 let mut entries: Vec<ManifestEntry> = Vec::new();
18 collect_entries(blobs, root, root, &mut entries)?;
19 entries.sort_by(|a, b| a.path.cmp(&b.path));
20 Ok(TreeManifest::new(entries, vec![]))
21}
22
23fn collect_entries(
24 blobs: &TreeBlobStore,
25 root: &Path,
26 dir: &Path,
27 entries: &mut Vec<ManifestEntry>,
28) -> Result<(), TreeError> {
29 for entry in fs::read_dir(dir)? {
30 let entry = entry?;
31 let path = entry.path();
32 let file_type = entry.file_type()?;
33
34 if file_type.is_symlink() {
35 continue;
37 }
38
39 if file_type.is_dir() {
40 collect_entries(blobs, root, &path, entries)?;
41 } else if file_type.is_file() {
42 let metadata = fs::metadata(&path)?;
43 let content = fs::read(&path)?;
44 let key = blobs.put_file_blob(&content)?;
45 let mode = (metadata.mode() & 0o7777) as u32;
46 let mtime_secs = metadata.mtime();
47 let mtime_nanos = metadata.mtime_nsec() as u32;
48 let rel = path
49 .strip_prefix(root)
50 .expect("path must be under root")
51 .to_path_buf();
52 entries.push(ManifestEntry::entry_for(
53 rel,
54 key,
55 mode,
56 (mtime_secs, mtime_nanos),
57 ));
58 }
59 }
60 Ok(())
61}
62
63pub fn restore_tree(
64 blobs: &TreeBlobStore,
65 manifest: &TreeManifest,
66 dest: &Path,
67) -> Result<(), TreeError> {
68 for entry in &manifest.entries {
69 let target = dest.join(&entry.path);
70 if let Some(parent) = target.parent() {
71 fs::create_dir_all(parent)?;
72 }
73
74 let bytes =
75 blobs
76 .get_file_blob(&entry.content_hash)?
77 .ok_or_else(|| TreeError::BlobMissing {
78 path: entry.path.clone(),
79 })?;
80
81 fs::write(&target, &bytes)?;
82
83 use std::os::unix::fs::PermissionsExt;
85 fs::set_permissions(&target, fs::Permissions::from_mode(entry.mode))?;
86
87 let ft = filetime::FileTime::from_unix_time(entry.mtime_secs, entry.mtime_nanos);
90 filetime::set_file_mtime(&target, ft)?;
91 }
92
93 for deleted in &manifest.deleted {
94 let target = dest.join(deleted);
95 if target.exists() {
96 fs::remove_file(&target)?;
97 }
98 }
99
100 Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::cas::TreeBlobStore;
107 use crate::store::FileStore;
108 use filetime::FileTime;
109 use std::path::PathBuf;
110 use tempfile::TempDir;
111
112 fn setup_store() -> (TempDir, FileStore) {
113 let dir = TempDir::new().unwrap();
114 let store = FileStore::open(dir.path()).unwrap();
115 (dir, store)
116 }
117
118 #[test]
119 fn round_trip_fidelity() {
120 let (_store_dir, store) = setup_store();
121 let blobs = TreeBlobStore::new(&store);
122
123 let src_dir = TempDir::new().unwrap();
124 let nested = src_dir.path().join("sub/dir");
125 fs::create_dir_all(&nested).unwrap();
126
127 let file_a = src_dir.path().join("top.txt");
128 fs::write(&file_a, b"hello top").unwrap();
129
130 let file_b = nested.join("deep.bin");
131 fs::write(&file_b, b"\x00\x01\x02\x03").unwrap();
132
133 let known_mtime = FileTime::from_unix_time(1_700_000_000, 123_456_789);
135 filetime::set_file_mtime(&file_a, known_mtime).unwrap();
136
137 use std::os::unix::fs::PermissionsExt;
139 let mode_a = fs::metadata(&file_a).unwrap().permissions().mode() & 0o7777;
140 let mode_b = fs::metadata(&file_b).unwrap().permissions().mode() & 0o7777;
141 let mtime_b = {
142 use std::os::unix::fs::MetadataExt;
143 let md = fs::metadata(&file_b).unwrap();
144 FileTime::from_unix_time(md.mtime(), md.mtime_nsec() as u32)
145 };
146
147 let manifest = capture_tree(&blobs, src_dir.path()).unwrap();
148 assert_eq!(manifest.entries.len(), 2);
149 assert!(manifest.deleted.is_empty());
150
151 let dest_dir = TempDir::new().unwrap();
152 restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
153
154 let restored_a = fs::read(dest_dir.path().join("top.txt")).unwrap();
156 assert_eq!(restored_a, b"hello top");
157 let restored_b = fs::read(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
158 assert_eq!(restored_b, b"\x00\x01\x02\x03");
159
160 let restored_mode_a = fs::metadata(dest_dir.path().join("top.txt"))
162 .unwrap()
163 .permissions()
164 .mode()
165 & 0o7777;
166 let restored_mode_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin"))
167 .unwrap()
168 .permissions()
169 .mode()
170 & 0o7777;
171 assert_eq!(restored_mode_a, mode_a);
172 assert_eq!(restored_mode_b, mode_b);
173
174 use std::os::unix::fs::MetadataExt;
176 let md_a = fs::metadata(dest_dir.path().join("top.txt")).unwrap();
177 assert_eq!(md_a.mtime(), 1_700_000_000);
178 assert_eq!(md_a.mtime_nsec() as u32, 123_456_789);
179
180 let md_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
181 assert_eq!(
182 FileTime::from_unix_time(md_b.mtime(), md_b.mtime_nsec() as u32),
183 mtime_b
184 );
185 }
186
187 #[test]
188 fn deletion_removes_file_from_dest() {
189 let (_store_dir, store) = setup_store();
190 let blobs = TreeBlobStore::new(&store);
191
192 let dest_dir = TempDir::new().unwrap();
193 let target = dest_dir.path().join("to_delete.txt");
194 fs::write(&target, b"present").unwrap();
195 assert!(target.exists());
196
197 let manifest = TreeManifest::new(vec![], vec![PathBuf::from("to_delete.txt")]);
198 restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
199
200 assert!(!target.exists(), "deleted path must be removed from dest");
201 }
202
203 #[test]
204 fn missing_blob_returns_error() {
205 let (_store_dir, store) = setup_store();
206 let blobs = TreeBlobStore::new(&store);
207 let dest_dir = TempDir::new().unwrap();
208
209 let phantom_key = crate::store::Key::from_bytes(b"never-stored");
210 let entry =
211 ManifestEntry::entry_for(PathBuf::from("ghost.txt"), phantom_key, 0o644, (0, 0));
212 let manifest = TreeManifest::new(vec![entry], vec![]);
213 let err = restore_tree(&blobs, &manifest, dest_dir.path())
214 .expect_err("must error on missing blob");
215 assert!(
216 matches!(err, TreeError::BlobMissing { .. }),
217 "unexpected error variant: {err:?}"
218 );
219 }
220}