Skip to main content

verdant_runtime/
tree.rs

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            // symlinks out of scope for M4
38            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    // restore may target a dest that does not exist yet (a build output dir
71    // wiped before replay), so create it before canonicalizing for the
72    // symlink bounds check below.
73    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        // A pre-existing symlink at the destination would be followed by
82        // fs::write, letting a replayed write escape the project root.
83        // safety: refuse to write through a symlink whose real path leaves
84        // the destination root (or is broken); a legitimate capture never
85        // targets a symlink because overlayfs copies up the link's target,
86        // not the link itself.
87        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        // Set permissions before mtime so the permission change doesn't bump mtime.
110        use std::os::unix::fs::PermissionsExt;
111        fs::set_permissions(&target, fs::Permissions::from_mode(entry.mode))?;
112
113        // Set mtime after writing and after permissions, because both operations
114        // would otherwise reset it.
115        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        // Set a precise mtime with non-zero nanoseconds on file_a.
160        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        // Capture permissions before capture so we can verify restoration.
164        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        // Verify contents.
181        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        // Verify permissions.
187        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        // Verify mtimes including nanoseconds.
201        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}