Skip to main content

verdant_runtime/
tree.rs

1use crate::cas::{CasError, ManifestEntry, TreeBlobStore, TreeManifest};
2use crate::tree_txn::{transact, DirReplace, Source};
3use std::os::unix::fs::MetadataExt;
4use std::path::{Path, PathBuf};
5use std::{fs, io};
6
7#[derive(Debug, thiserror::Error)]
8pub enum TreeError {
9    #[error("io error: {0}")]
10    Io(#[from] io::Error),
11    #[error("cas error: {0}")]
12    Cas(#[from] CasError),
13    #[error("blob missing for {path}")]
14    BlobMissing { path: PathBuf },
15    #[error("restore path escapes destination root: {path}")]
16    PathEscape { path: PathBuf },
17    #[error("replay would remove the non-empty directory {path}")]
18    NonEmptyDirectory { path: PathBuf },
19    #[error("replay expects a directory at {path}, where the tree holds none")]
20    NotADirectory { path: PathBuf },
21    #[error("replay would create {path} where another entry exists")]
22    Occupied { path: PathBuf },
23    #[error("{cause}; undoing the part already applied failed: {}", failures.join("; "))]
24    RollbackIncomplete {
25        cause: Box<TreeError>,
26        failures: Vec<String>,
27    },
28}
29
30pub fn capture_tree(blobs: &TreeBlobStore, root: &Path) -> Result<TreeManifest, TreeError> {
31    let mut entries: Vec<ManifestEntry> = Vec::new();
32    collect_entries(blobs, root, root, &mut entries)?;
33    entries.sort_by(|a, b| a.path.cmp(&b.path));
34    Ok(TreeManifest::new(entries, vec![]))
35}
36
37fn collect_entries(
38    blobs: &TreeBlobStore,
39    root: &Path,
40    dir: &Path,
41    entries: &mut Vec<ManifestEntry>,
42) -> Result<(), TreeError> {
43    for entry in fs::read_dir(dir)? {
44        let entry = entry?;
45        let path = entry.path();
46        let file_type = entry.file_type()?;
47
48        if file_type.is_symlink() {
49            // symlinks out of scope for M4
50            continue;
51        }
52
53        if file_type.is_dir() {
54            collect_entries(blobs, root, &path, entries)?;
55        } else if file_type.is_file() {
56            let metadata = fs::metadata(&path)?;
57            let content = fs::read(&path)?;
58            let key = blobs.put_file_blob(&content)?;
59            let mode = (metadata.mode() & 0o7777) as u32;
60            let mtime_secs = metadata.mtime();
61            let mtime_nanos = metadata.mtime_nsec() as u32;
62            let rel = path
63                .strip_prefix(root)
64                .expect("path must be under root")
65                .to_path_buf();
66            entries.push(ManifestEntry::entry_for(
67                rel,
68                key,
69                mode,
70                (mtime_secs, mtime_nanos),
71            ));
72        }
73    }
74    Ok(())
75}
76
77/// Replays a manifest onto `dest`, which may have changed since the run it
78/// records, so a deleted directory is removed only while it is empty. Any
79/// failure leaves `dest` as it was (see [`transact`]).
80pub fn restore_tree(
81    blobs: &TreeBlobStore,
82    manifest: &TreeManifest,
83    dest: &Path,
84) -> Result<(), TreeError> {
85    // restore may target a dest that does not exist yet (a build output dir
86    // wiped before replay), so create it before canonicalizing for the
87    // symlink bounds check.
88    fs::create_dir_all(dest)?;
89    transact(dest, DirReplace::EmptyOnly, |txn| {
90        for dir in &manifest.dir_attrs {
91            let mtime = dir
92                .mtime
93                .map(|(secs, nanos)| filetime::FileTime::from_unix_time(secs, nanos));
94            txn.set_dir_attrs(&dir.path, dir.mode, mtime)?;
95        }
96        for deleted in &manifest.deleted {
97            txn.remove(deleted)?;
98        }
99        for dir in &manifest.dirs {
100            let mtime = filetime::FileTime::from_unix_time(dir.mtime_secs, dir.mtime_nanos);
101            txn.create_dir(&dir.path, dir.mode, mtime)?;
102        }
103        for link in &manifest.links {
104            let mtime = filetime::FileTime::from_unix_time(link.mtime_secs, link.mtime_nanos);
105            txn.create_symlink(&link.path, &link.target, mtime)?;
106        }
107        for entry in &manifest.entries {
108            let bytes = blobs.get_file_blob(&entry.content_hash)?.ok_or_else(|| {
109                TreeError::BlobMissing {
110                    path: entry.path.clone(),
111                }
112            })?;
113            let mtime = filetime::FileTime::from_unix_time(entry.mtime_secs, entry.mtime_nanos);
114            txn.write_file(&entry.path, Source::Bytes(&bytes), entry.mode, mtime)?;
115        }
116        Ok(())
117    })
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::cas::{ManifestDir, ManifestDirAttrs, ManifestLink, TreeBlobStore};
124    use crate::store::FileStore;
125    use filetime::FileTime;
126    use std::os::unix::fs::PermissionsExt;
127    use std::path::PathBuf;
128    use tempfile::TempDir;
129
130    fn setup_store() -> (TempDir, FileStore) {
131        let dir = TempDir::new().unwrap();
132        let store = FileStore::open(dir.path()).unwrap();
133        (dir, store)
134    }
135
136    #[test]
137    fn round_trip_fidelity() {
138        let (_store_dir, store) = setup_store();
139        let blobs = TreeBlobStore::new(&store);
140
141        let src_dir = TempDir::new().unwrap();
142        let nested = src_dir.path().join("sub/dir");
143        fs::create_dir_all(&nested).unwrap();
144
145        let file_a = src_dir.path().join("top.txt");
146        fs::write(&file_a, b"hello top").unwrap();
147
148        let file_b = nested.join("deep.bin");
149        fs::write(&file_b, b"\x00\x01\x02\x03").unwrap();
150
151        // Set a precise mtime with non-zero nanoseconds on file_a.
152        let known_mtime = FileTime::from_unix_time(1_700_000_000, 123_456_789);
153        filetime::set_file_mtime(&file_a, known_mtime).unwrap();
154
155        // Capture permissions before capture so we can verify restoration.
156        use std::os::unix::fs::PermissionsExt;
157        let mode_a = fs::metadata(&file_a).unwrap().permissions().mode() & 0o7777;
158        let mode_b = fs::metadata(&file_b).unwrap().permissions().mode() & 0o7777;
159        let mtime_b = {
160            use std::os::unix::fs::MetadataExt;
161            let md = fs::metadata(&file_b).unwrap();
162            FileTime::from_unix_time(md.mtime(), md.mtime_nsec() as u32)
163        };
164
165        let manifest = capture_tree(&blobs, src_dir.path()).unwrap();
166        assert_eq!(manifest.entries.len(), 2);
167        assert!(manifest.deleted.is_empty());
168
169        let dest_dir = TempDir::new().unwrap();
170        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
171
172        // Verify contents.
173        let restored_a = fs::read(dest_dir.path().join("top.txt")).unwrap();
174        assert_eq!(restored_a, b"hello top");
175        let restored_b = fs::read(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
176        assert_eq!(restored_b, b"\x00\x01\x02\x03");
177
178        // Verify permissions.
179        let restored_mode_a = fs::metadata(dest_dir.path().join("top.txt"))
180            .unwrap()
181            .permissions()
182            .mode()
183            & 0o7777;
184        let restored_mode_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin"))
185            .unwrap()
186            .permissions()
187            .mode()
188            & 0o7777;
189        assert_eq!(restored_mode_a, mode_a);
190        assert_eq!(restored_mode_b, mode_b);
191
192        // Verify mtimes including nanoseconds.
193        use std::os::unix::fs::MetadataExt;
194        let md_a = fs::metadata(dest_dir.path().join("top.txt")).unwrap();
195        assert_eq!(md_a.mtime(), 1_700_000_000);
196        assert_eq!(md_a.mtime_nsec() as u32, 123_456_789);
197
198        let md_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
199        assert_eq!(
200            FileTime::from_unix_time(md_b.mtime(), md_b.mtime_nsec() as u32),
201            mtime_b
202        );
203    }
204
205    #[test]
206    fn deletion_removes_file_from_dest() {
207        let (_store_dir, store) = setup_store();
208        let blobs = TreeBlobStore::new(&store);
209
210        let dest_dir = TempDir::new().unwrap();
211        let target = dest_dir.path().join("to_delete.txt");
212        fs::write(&target, b"present").unwrap();
213        assert!(target.exists());
214
215        let manifest = TreeManifest::new(vec![], vec![PathBuf::from("to_delete.txt")]);
216        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
217
218        assert!(!target.exists(), "deleted path must be removed from dest");
219    }
220
221    #[test]
222    fn restore_refuses_symlink_escaping_root() {
223        let (_store_dir, store) = setup_store();
224        let blobs = TreeBlobStore::new(&store);
225
226        let outside = TempDir::new().unwrap();
227        let victim = outside.path().join("victim.txt");
228        fs::write(&victim, b"original").unwrap();
229
230        let dest_dir = TempDir::new().unwrap();
231        let link = dest_dir.path().join("link");
232        std::os::unix::fs::symlink(&victim, &link).unwrap();
233
234        let key = blobs.put_file_blob(b"pwned").unwrap();
235        let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
236        let manifest = TreeManifest::new(vec![entry], vec![]);
237
238        let err = restore_tree(&blobs, &manifest, dest_dir.path())
239            .expect_err("must refuse a symlink escaping root");
240        assert!(
241            matches!(err, TreeError::PathEscape { .. }),
242            "unexpected error variant: {err:?}"
243        );
244        assert_eq!(fs::read(&victim).unwrap(), b"original", "victim untouched");
245    }
246
247    #[test]
248    fn restore_allows_symlink_inside_root() {
249        let (_store_dir, store) = setup_store();
250        let blobs = TreeBlobStore::new(&store);
251
252        let dest_dir = TempDir::new().unwrap();
253        let real = dest_dir.path().join("real.txt");
254        fs::write(&real, b"original").unwrap();
255        let link = dest_dir.path().join("link");
256        std::os::unix::fs::symlink(&real, &link).unwrap();
257
258        let key = blobs.put_file_blob(b"updated").unwrap();
259        let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
260        let manifest = TreeManifest::new(vec![entry], vec![]);
261
262        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
263        assert_eq!(fs::read(&real).unwrap(), b"updated");
264    }
265
266    #[test]
267    fn missing_blob_returns_error() {
268        let (_store_dir, store) = setup_store();
269        let blobs = TreeBlobStore::new(&store);
270        let dest_dir = TempDir::new().unwrap();
271
272        let phantom_key = crate::store::Key::from_bytes(b"never-stored");
273        let entry =
274            ManifestEntry::entry_for(PathBuf::from("ghost.txt"), phantom_key, 0o644, (0, 0));
275        let manifest = TreeManifest::new(vec![entry], vec![]);
276        let err = restore_tree(&blobs, &manifest, dest_dir.path())
277            .expect_err("must error on missing blob");
278        assert!(
279            matches!(err, TreeError::BlobMissing { .. }),
280            "unexpected error variant: {err:?}"
281        );
282    }
283    fn mtime_of(path: &Path) -> (i64, i64) {
284        let meta = fs::symlink_metadata(path).unwrap();
285        (meta.mtime(), meta.mtime_nsec())
286    }
287
288    #[test]
289    fn deletion_removes_an_empty_directory() {
290        let (_store_dir, store) = setup_store();
291        let blobs = TreeBlobStore::new(&store);
292        let dest_dir = TempDir::new().unwrap();
293        fs::create_dir(dest_dir.path().join("dist")).unwrap();
294        fs::write(dest_dir.path().join("log"), b"a\n").unwrap();
295
296        let key = blobs.put_file_blob(b"a\nx\n").unwrap();
297        let entry = ManifestEntry::entry_for(PathBuf::from("log"), key, 0o644, (0, 0));
298        let manifest = TreeManifest::new(vec![entry], vec![PathBuf::from("dist")]);
299        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
300
301        assert!(!dest_dir.path().join("dist").exists());
302        assert_eq!(fs::read(dest_dir.path().join("log")).unwrap(), b"a\nx\n");
303    }
304
305    #[test]
306    fn a_file_replaces_an_empty_directory() {
307        let (_store_dir, store) = setup_store();
308        let blobs = TreeBlobStore::new(&store);
309        let dest_dir = TempDir::new().unwrap();
310        fs::create_dir(dest_dir.path().join("dist")).unwrap();
311
312        let key = blobs.put_file_blob(b"now a file").unwrap();
313        let entry = ManifestEntry::entry_for(PathBuf::from("dist"), key, 0o644, (0, 0));
314        restore_tree(
315            &blobs,
316            &TreeManifest::new(vec![entry], vec![]),
317            dest_dir.path(),
318        )
319        .unwrap();
320
321        assert_eq!(
322            fs::read(dest_dir.path().join("dist")).unwrap(),
323            b"now a file"
324        );
325    }
326
327    #[test]
328    fn a_replay_never_removes_a_non_empty_directory_and_writes_nothing() {
329        let (_store_dir, store) = setup_store();
330        let blobs = TreeBlobStore::new(&store);
331        let dest_dir = TempDir::new().unwrap();
332        let dest = dest_dir.path();
333        fs::create_dir(dest.join("dist")).unwrap();
334        fs::write(dest.join("dist/kept"), b"kept").unwrap();
335        fs::write(dest.join("log"), b"a\n").unwrap();
336
337        let key = blobs.put_file_blob(b"a\nx\n").unwrap();
338        let entry = ManifestEntry::entry_for(PathBuf::from("log"), key, 0o644, (0, 0));
339        let manifest = TreeManifest::new(vec![entry], vec![PathBuf::from("dist")]);
340        let err = restore_tree(&blobs, &manifest, dest).expect_err("must refuse");
341
342        assert!(
343            matches!(err, TreeError::NonEmptyDirectory { .. }),
344            "unexpected error variant: {err:?}"
345        );
346        assert_eq!(fs::read(dest.join("dist/kept")).unwrap(), b"kept");
347        assert_eq!(fs::read(dest.join("log")).unwrap(), b"a\n");
348    }
349
350    #[test]
351    fn a_failed_restore_leaves_the_tree_as_it_was() {
352        let (_store_dir, store) = setup_store();
353        let blobs = TreeBlobStore::new(&store);
354        let outside = TempDir::new().unwrap();
355        let victim = outside.path().join("victim.txt");
356        fs::write(&victim, b"original").unwrap();
357
358        let dest_dir = TempDir::new().unwrap();
359        let dest = dest_dir.path();
360        fs::write(dest.join("a.txt"), b"old").unwrap();
361        fs::set_permissions(dest.join("a.txt"), fs::Permissions::from_mode(0o600)).unwrap();
362        filetime::set_file_mtime(
363            dest.join("a.txt"),
364            FileTime::from_unix_time(1_600_000_000, 5),
365        )
366        .unwrap();
367        fs::create_dir(dest.join("gone")).unwrap();
368        fs::write(dest.join("gone.txt"), b"still here").unwrap();
369        std::os::unix::fs::symlink(&victim, dest.join("z_link")).unwrap();
370        let before = mtime_of(&dest.join("a.txt"));
371
372        let entries = vec![
373            ManifestEntry::entry_for(
374                PathBuf::from("a.txt"),
375                blobs.put_file_blob(b"new").unwrap(),
376                0o644,
377                (0, 0),
378            ),
379            ManifestEntry::entry_for(
380                PathBuf::from("fresh/dir/b.txt"),
381                blobs.put_file_blob(b"b").unwrap(),
382                0o644,
383                (0, 0),
384            ),
385            ManifestEntry::entry_for(
386                PathBuf::from("z_link"),
387                blobs.put_file_blob(b"pwned").unwrap(),
388                0o644,
389                (0, 0),
390            ),
391        ];
392        let deleted = vec![PathBuf::from("gone"), PathBuf::from("gone.txt")];
393        let err = restore_tree(&blobs, &TreeManifest::new(entries, deleted), dest)
394            .expect_err("the escaping symlink must fail the restore");
395
396        assert!(
397            matches!(err, TreeError::PathEscape { .. }),
398            "unexpected error variant: {err:?}"
399        );
400        assert_eq!(fs::read(dest.join("a.txt")).unwrap(), b"old");
401        let meta = fs::metadata(dest.join("a.txt")).unwrap();
402        assert_eq!(meta.permissions().mode() & 0o7777, 0o600);
403        assert_eq!(mtime_of(&dest.join("a.txt")), before);
404        assert!(!dest.join("fresh").exists(), "created dirs must be removed");
405        assert!(dest.join("gone").is_dir(), "removed dir must come back");
406        assert_eq!(fs::read(dest.join("gone.txt")).unwrap(), b"still here");
407        assert_eq!(fs::read(&victim).unwrap(), b"original");
408        let mut names: Vec<_> = fs::read_dir(dest)
409            .unwrap()
410            .map(|e| e.unwrap().file_name())
411            .collect();
412        names.sort();
413        assert_eq!(
414            names,
415            ["a.txt", "gone", "gone.txt", "z_link"],
416            "no backups left"
417        );
418    }
419
420    #[test]
421    fn restore_refuses_a_write_through_a_parent_symlink_escaping_root() {
422        let (_store_dir, store) = setup_store();
423        let blobs = TreeBlobStore::new(&store);
424        let outside = TempDir::new().unwrap();
425        let dest_dir = TempDir::new().unwrap();
426        std::os::unix::fs::symlink(outside.path(), dest_dir.path().join("sub")).unwrap();
427
428        let key = blobs.put_file_blob(b"pwned").unwrap();
429        let entry = ManifestEntry::entry_for(PathBuf::from("sub/new/x.txt"), key, 0o644, (0, 0));
430        let err = restore_tree(
431            &blobs,
432            &TreeManifest::new(vec![entry], vec![]),
433            dest_dir.path(),
434        )
435        .expect_err("must refuse");
436
437        assert!(
438            matches!(err, TreeError::PathEscape { .. }),
439            "unexpected error variant: {err:?}"
440        );
441        assert!(!outside.path().join("new").exists());
442    }
443
444    #[test]
445    fn a_deletion_through_a_parent_symlink_escaping_root_is_refused() {
446        let (_store_dir, store) = setup_store();
447        let blobs = TreeBlobStore::new(&store);
448        let outside = TempDir::new().unwrap();
449        fs::write(outside.path().join("victim.txt"), b"original").unwrap();
450        let dest_dir = TempDir::new().unwrap();
451        std::os::unix::fs::symlink(outside.path(), dest_dir.path().join("sub")).unwrap();
452
453        let manifest = TreeManifest::new(vec![], vec![PathBuf::from("sub/victim.txt")]);
454        let err = restore_tree(&blobs, &manifest, dest_dir.path()).expect_err("must refuse");
455
456        assert!(
457            matches!(err, TreeError::PathEscape { .. }),
458            "unexpected error variant: {err:?}"
459        );
460        assert!(outside.path().join("victim.txt").exists());
461    }
462
463    #[test]
464    fn restore_replaces_a_deleted_link_with_a_created_directory_before_its_files() {
465        let (_store_dir, store) = setup_store();
466        let blobs = TreeBlobStore::new(&store);
467        let dest = TempDir::new().unwrap();
468        fs::create_dir(dest.path().join("kept")).unwrap();
469        std::os::unix::fs::symlink("kept", dest.path().join("current")).unwrap();
470        let key = blobs.put_file_blob(b"x").unwrap();
471        let mut manifest = TreeManifest::new(
472            vec![ManifestEntry::entry_for(
473                PathBuf::from("current/f"),
474                key,
475                0o644,
476                (1, 0),
477            )],
478            vec![PathBuf::from("current")],
479        );
480        manifest.dirs.push(ManifestDir {
481            path: PathBuf::from("current"),
482            mode: 0o555,
483            mtime_secs: 1_700_000_000,
484            mtime_nanos: 9,
485        });
486
487        restore_tree(&blobs, &manifest, dest.path()).unwrap();
488
489        let current = fs::symlink_metadata(dest.path().join("current")).unwrap();
490        assert!(current.is_dir());
491        assert_eq!(current.permissions().mode() & 0o7777, 0o555);
492        assert_eq!(
493            FileTime::from_last_modification_time(&current),
494            FileTime::from_unix_time(1_700_000_000, 9)
495        );
496        assert_eq!(fs::read(dest.path().join("current/f")).unwrap(), b"x");
497        assert_eq!(fs::read_dir(dest.path().join("kept")).unwrap().count(), 0);
498        fs::set_permissions(
499            dest.path().join("current"),
500            fs::Permissions::from_mode(0o700),
501        )
502        .unwrap();
503    }
504
505    #[test]
506    fn restore_replays_links_and_the_attributes_of_kept_directories() {
507        let (_store_dir, store) = setup_store();
508        let blobs = TreeBlobStore::new(&store);
509        let dest = TempDir::new().unwrap();
510        let d = dest.path().join("d");
511        fs::create_dir(&d).unwrap();
512        fs::write(d.join("old"), b"x").unwrap();
513        fs::write(dest.path().join("f"), b"x").unwrap();
514        let mut manifest =
515            TreeManifest::new(vec![], vec![PathBuf::from("d/old"), PathBuf::from("f")]);
516        for (path, target) in [("f", "t"), ("d/l", "../nowhere")] {
517            manifest.links.push(ManifestLink {
518                path: PathBuf::from(path),
519                target: PathBuf::from(target),
520                mtime_secs: 1_600_000_000,
521                mtime_nanos: 3,
522            });
523        }
524        manifest.dir_attrs.push(ManifestDirAttrs {
525            path: PathBuf::from("d"),
526            mode: Some(0o500),
527            mtime: Some((1_700_000_000, 9)),
528        });
529
530        restore_tree(&blobs, &manifest, dest.path()).unwrap();
531
532        assert_eq!(
533            fs::read_link(dest.path().join("f")).unwrap(),
534            Path::new("t")
535        );
536        let link = fs::symlink_metadata(d.join("l")).unwrap();
537        assert_eq!(fs::read_link(d.join("l")).unwrap(), Path::new("../nowhere"));
538        assert_eq!(
539            FileTime::from_last_modification_time(&link),
540            FileTime::from_unix_time(1_600_000_000, 3)
541        );
542        let meta = fs::metadata(&d).unwrap();
543        assert_eq!(meta.permissions().mode() & 0o7777, 0o500);
544        assert_eq!(
545            FileTime::from_last_modification_time(&meta),
546            FileTime::from_unix_time(1_700_000_000, 9)
547        );
548        fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).unwrap();
549        let names: Vec<_> = fs::read_dir(&d)
550            .unwrap()
551            .map(|e| e.unwrap().file_name())
552            .collect();
553        assert_eq!(names, ["l"]);
554    }
555
556    #[test]
557    fn a_failed_restore_leaves_a_recorded_directory_as_it_was() {
558        let (_store_dir, store) = setup_store();
559        let blobs = TreeBlobStore::new(&store);
560        let new_blob = blobs.put_file_blob(b"new").unwrap();
561        let gone_blob = blobs.put_file_blob(b"gone").unwrap();
562        store.remove(&gone_blob).unwrap();
563        let before = FileTime::from_unix_time(1_600_000_000, 7);
564
565        let occupied_link = {
566            let mut m = TreeManifest::new(vec![], vec![PathBuf::from("d/old")]);
567            m.links.push(ManifestLink {
568                path: PathBuf::from("d/taken"),
569                target: PathBuf::from("t"),
570                mtime_secs: 1,
571                mtime_nanos: 0,
572            });
573            m
574        };
575        let missing_blob = TreeManifest::new(
576            vec![
577                ManifestEntry::entry_for(PathBuf::from("d/new"), new_blob, 0o644, (1, 0)),
578                ManifestEntry::entry_for(PathBuf::from("e"), gone_blob, 0o644, (1, 0)),
579            ],
580            vec![],
581        );
582        for mut manifest in [occupied_link, missing_blob] {
583            let dest = TempDir::new().unwrap();
584            let d = dest.path().join("d");
585            fs::create_dir(&d).unwrap();
586            fs::write(d.join("old"), b"x").unwrap();
587            fs::write(d.join("taken"), b"x").unwrap();
588            fs::set_permissions(&d, fs::Permissions::from_mode(0o751)).unwrap();
589            filetime::set_file_mtime(&d, before).unwrap();
590            manifest.dir_attrs.push(ManifestDirAttrs {
591                path: PathBuf::from("d"),
592                mode: Some(0o500),
593                mtime: Some((1_700_000_000, 9)),
594            });
595
596            let err = restore_tree(&blobs, &manifest, dest.path())
597                .expect_err("a later entry cannot be replayed");
598
599            assert!(
600                matches!(
601                    err,
602                    TreeError::Occupied { .. } | TreeError::BlobMissing { .. }
603                ),
604                "{err:?}"
605            );
606            let meta = fs::metadata(&d).unwrap();
607            assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
608            assert_eq!(FileTime::from_last_modification_time(&meta), before);
609            let mut names: Vec<_> = fs::read_dir(&d)
610                .unwrap()
611                .map(|e| e.unwrap().file_name())
612                .collect();
613            names.sort();
614            assert_eq!(names, ["old", "taken"]);
615        }
616    }
617}