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 #[error("restore path escapes destination root: {path}")]
15 PathEscape { path: PathBuf },
16}
17
18pub fn capture_tree(blobs: &TreeBlobStore, root: &Path) -> Result<TreeManifest, TreeError> {
19 let mut entries: Vec<ManifestEntry> = Vec::new();
20 collect_entries(blobs, root, root, &mut entries)?;
21 entries.sort_by(|a, b| a.path.cmp(&b.path));
22 Ok(TreeManifest::new(entries, vec![]))
23}
24
25fn collect_entries(
26 blobs: &TreeBlobStore,
27 root: &Path,
28 dir: &Path,
29 entries: &mut Vec<ManifestEntry>,
30) -> Result<(), TreeError> {
31 for entry in fs::read_dir(dir)? {
32 let entry = entry?;
33 let path = entry.path();
34 let file_type = entry.file_type()?;
35
36 if file_type.is_symlink() {
37 continue;
39 }
40
41 if file_type.is_dir() {
42 collect_entries(blobs, root, &path, entries)?;
43 } else if file_type.is_file() {
44 let metadata = fs::metadata(&path)?;
45 let content = fs::read(&path)?;
46 let key = blobs.put_file_blob(&content)?;
47 let mode = (metadata.mode() & 0o7777) as u32;
48 let mtime_secs = metadata.mtime();
49 let mtime_nanos = metadata.mtime_nsec() as u32;
50 let rel = path
51 .strip_prefix(root)
52 .expect("path must be under root")
53 .to_path_buf();
54 entries.push(ManifestEntry::entry_for(
55 rel,
56 key,
57 mode,
58 (mtime_secs, mtime_nanos),
59 ));
60 }
61 }
62 Ok(())
63}
64
65pub fn restore_tree(
66 blobs: &TreeBlobStore,
67 manifest: &TreeManifest,
68 dest: &Path,
69) -> Result<(), TreeError> {
70 fs::create_dir_all(dest)?;
74 let dest_canonical = dest.canonicalize()?;
75 for entry in &manifest.entries {
76 let target = dest.join(&entry.path);
77 if let Some(parent) = target.parent() {
78 fs::create_dir_all(parent)?;
79 }
80
81 if let Ok(meta) = fs::symlink_metadata(&target) {
88 if meta.file_type().is_symlink() {
89 let resolved = target.canonicalize().map_err(|_| TreeError::PathEscape {
90 path: entry.path.clone(),
91 })?;
92 if !resolved.starts_with(&dest_canonical) {
93 return Err(TreeError::PathEscape {
94 path: entry.path.clone(),
95 });
96 }
97 }
98 }
99
100 let bytes =
101 blobs
102 .get_file_blob(&entry.content_hash)?
103 .ok_or_else(|| TreeError::BlobMissing {
104 path: entry.path.clone(),
105 })?;
106
107 fs::write(&target, &bytes)?;
108
109 use std::os::unix::fs::PermissionsExt;
111 fs::set_permissions(&target, fs::Permissions::from_mode(entry.mode))?;
112
113 let ft = filetime::FileTime::from_unix_time(entry.mtime_secs, entry.mtime_nanos);
116 filetime::set_file_mtime(&target, ft)?;
117 }
118
119 for deleted in &manifest.deleted {
120 let target = dest.join(deleted);
121 if target.exists() {
122 fs::remove_file(&target)?;
123 }
124 }
125
126 Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::cas::TreeBlobStore;
133 use crate::store::FileStore;
134 use filetime::FileTime;
135 use std::path::PathBuf;
136 use tempfile::TempDir;
137
138 fn setup_store() -> (TempDir, FileStore) {
139 let dir = TempDir::new().unwrap();
140 let store = FileStore::open(dir.path()).unwrap();
141 (dir, store)
142 }
143
144 #[test]
145 fn round_trip_fidelity() {
146 let (_store_dir, store) = setup_store();
147 let blobs = TreeBlobStore::new(&store);
148
149 let src_dir = TempDir::new().unwrap();
150 let nested = src_dir.path().join("sub/dir");
151 fs::create_dir_all(&nested).unwrap();
152
153 let file_a = src_dir.path().join("top.txt");
154 fs::write(&file_a, b"hello top").unwrap();
155
156 let file_b = nested.join("deep.bin");
157 fs::write(&file_b, b"\x00\x01\x02\x03").unwrap();
158
159 let known_mtime = FileTime::from_unix_time(1_700_000_000, 123_456_789);
161 filetime::set_file_mtime(&file_a, known_mtime).unwrap();
162
163 use std::os::unix::fs::PermissionsExt;
165 let mode_a = fs::metadata(&file_a).unwrap().permissions().mode() & 0o7777;
166 let mode_b = fs::metadata(&file_b).unwrap().permissions().mode() & 0o7777;
167 let mtime_b = {
168 use std::os::unix::fs::MetadataExt;
169 let md = fs::metadata(&file_b).unwrap();
170 FileTime::from_unix_time(md.mtime(), md.mtime_nsec() as u32)
171 };
172
173 let manifest = capture_tree(&blobs, src_dir.path()).unwrap();
174 assert_eq!(manifest.entries.len(), 2);
175 assert!(manifest.deleted.is_empty());
176
177 let dest_dir = TempDir::new().unwrap();
178 restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
179
180 let restored_a = fs::read(dest_dir.path().join("top.txt")).unwrap();
182 assert_eq!(restored_a, b"hello top");
183 let restored_b = fs::read(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
184 assert_eq!(restored_b, b"\x00\x01\x02\x03");
185
186 let restored_mode_a = fs::metadata(dest_dir.path().join("top.txt"))
188 .unwrap()
189 .permissions()
190 .mode()
191 & 0o7777;
192 let restored_mode_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin"))
193 .unwrap()
194 .permissions()
195 .mode()
196 & 0o7777;
197 assert_eq!(restored_mode_a, mode_a);
198 assert_eq!(restored_mode_b, mode_b);
199
200 use std::os::unix::fs::MetadataExt;
202 let md_a = fs::metadata(dest_dir.path().join("top.txt")).unwrap();
203 assert_eq!(md_a.mtime(), 1_700_000_000);
204 assert_eq!(md_a.mtime_nsec() as u32, 123_456_789);
205
206 let md_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
207 assert_eq!(
208 FileTime::from_unix_time(md_b.mtime(), md_b.mtime_nsec() as u32),
209 mtime_b
210 );
211 }
212
213 #[test]
214 fn deletion_removes_file_from_dest() {
215 let (_store_dir, store) = setup_store();
216 let blobs = TreeBlobStore::new(&store);
217
218 let dest_dir = TempDir::new().unwrap();
219 let target = dest_dir.path().join("to_delete.txt");
220 fs::write(&target, b"present").unwrap();
221 assert!(target.exists());
222
223 let manifest = TreeManifest::new(vec![], vec![PathBuf::from("to_delete.txt")]);
224 restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
225
226 assert!(!target.exists(), "deleted path must be removed from dest");
227 }
228
229 #[test]
230 fn restore_refuses_symlink_escaping_root() {
231 let (_store_dir, store) = setup_store();
232 let blobs = TreeBlobStore::new(&store);
233
234 let outside = TempDir::new().unwrap();
235 let victim = outside.path().join("victim.txt");
236 fs::write(&victim, b"original").unwrap();
237
238 let dest_dir = TempDir::new().unwrap();
239 let link = dest_dir.path().join("link");
240 std::os::unix::fs::symlink(&victim, &link).unwrap();
241
242 let key = blobs.put_file_blob(b"pwned").unwrap();
243 let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
244 let manifest = TreeManifest::new(vec![entry], vec![]);
245
246 let err = restore_tree(&blobs, &manifest, dest_dir.path())
247 .expect_err("must refuse a symlink escaping root");
248 assert!(
249 matches!(err, TreeError::PathEscape { .. }),
250 "unexpected error variant: {err:?}"
251 );
252 assert_eq!(fs::read(&victim).unwrap(), b"original", "victim untouched");
253 }
254
255 #[test]
256 fn restore_allows_symlink_inside_root() {
257 let (_store_dir, store) = setup_store();
258 let blobs = TreeBlobStore::new(&store);
259
260 let dest_dir = TempDir::new().unwrap();
261 let real = dest_dir.path().join("real.txt");
262 fs::write(&real, b"original").unwrap();
263 let link = dest_dir.path().join("link");
264 std::os::unix::fs::symlink(&real, &link).unwrap();
265
266 let key = blobs.put_file_blob(b"updated").unwrap();
267 let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
268 let manifest = TreeManifest::new(vec![entry], vec![]);
269
270 restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
271 assert_eq!(fs::read(&real).unwrap(), b"updated");
272 }
273
274 #[test]
275 fn missing_blob_returns_error() {
276 let (_store_dir, store) = setup_store();
277 let blobs = TreeBlobStore::new(&store);
278 let dest_dir = TempDir::new().unwrap();
279
280 let phantom_key = crate::store::Key::from_bytes(b"never-stored");
281 let entry =
282 ManifestEntry::entry_for(PathBuf::from("ghost.txt"), phantom_key, 0o644, (0, 0));
283 let manifest = TreeManifest::new(vec![entry], vec![]);
284 let err = restore_tree(&blobs, &manifest, dest_dir.path())
285 .expect_err("must error on missing blob");
286 assert!(
287 matches!(err, TreeError::BlobMissing { .. }),
288 "unexpected error variant: {err:?}"
289 );
290 }
291}