Skip to main content

snapdir_stores/
file_store.rs

1//! `FileStore`: the `file://` storage backend.
2//!
3//! A [`FileStore`] is rooted at a local directory and holds the frozen
4//! content-addressable `.objects`/`.manifests` sharded layout, so a store
5//! directory written by any conforming implementation is interchangeable:
6//!
7//! ```text
8//! <root>/.objects/<sharded checksum>     raw file bytes
9//! <root>/.manifests/<sharded snapshot id> manifest text
10//! ```
11//!
12//! Sharding and the on-disk paths come straight from [`snapdir_core::store`]
13//! ([`object_path`] / [`manifest_path`]); this module never reimplements them.
14//!
15//! # Oracle parity
16//!
17//! - **`new` / URL parsing** mirrors `_snapdir_file_store_get_store_dir`:
18//!   strips a leading `file://`, `file:///`, `file://localhost/` (etc.) prefix
19//!   down to an absolute path and drops a trailing slash.
20//! - **`push`** mirrors `snapdir_file_store_get_push_command` +
21//!   `_snapdir_file_store_persit`: it is a no-op if the manifest already exists
22//!   (skip-if-present); otherwise it writes every referenced object that is
23//!   absent (skip-if-present per object) *before* writing the manifest, so a
24//!   present manifest always implies all of its objects are present.
25//! - **`fetch_files` / `get_manifest`** mirror the fetch side of
26//!   `_snapdir_file_store_persit`: copy to a temp path, verify the content
27//!   BLAKE3 against the expected checksum, retry up to five times, then
28//!   atomically rename into place.
29//!
30//! All I/O is native in-process filesystem I/O; nothing shells out.
31
32use std::fs;
33use std::io;
34use std::path::{Path, PathBuf};
35
36use snapdir_core::manifest::{Manifest, PathType};
37use snapdir_core::merkle::{Blake3Hasher, Hasher};
38use snapdir_core::store::{manifest_path, object_path, Store, StoreError};
39
40/// Number of times the oracle retries a persist whose copied bytes fail their
41/// checksum but whose source still verifies (`_SNAPDIR_FILE_STORE_RETRIES`).
42const MAX_PERSIST_RETRIES: u32 = 5;
43
44/// A content-addressable store backed by a local directory (the `file://`
45/// backend).
46///
47/// Construct one with [`FileStore::new`] (parsing a `file://` URL or a bare
48/// path) or [`FileStore::from_root`] (an already-resolved directory).
49#[derive(Debug, Clone)]
50pub struct FileStore {
51    root: PathBuf,
52}
53
54impl FileStore {
55    /// Builds a store from a `store` URL or path, matching the oracle's
56    /// `_snapdir_file_store_get_store_dir`.
57    ///
58    /// Accepts `file:///abs/path`, `file://localhost/abs/path`, `file://`
59    /// followed by an absolute path, or a bare absolute path. A leading
60    /// `file:` scheme (with any number of slashes, optionally `localhost`) is
61    /// rewritten to a single leading `/`, and a trailing slash is dropped.
62    #[must_use]
63    pub fn new(store: &str) -> Self {
64        Self::from_root(parse_store_dir(store))
65    }
66
67    /// Builds a store rooted at an already-resolved directory.
68    #[must_use]
69    pub fn from_root(root: impl Into<PathBuf>) -> Self {
70        Self { root: root.into() }
71    }
72
73    /// Returns the store's root directory.
74    #[must_use]
75    pub fn root(&self) -> &Path {
76        &self.root
77    }
78
79    /// Absolute on-disk path of an object given its checksum.
80    fn object_disk_path(&self, checksum: &str) -> PathBuf {
81        self.root.join(object_path(checksum))
82    }
83
84    /// Absolute on-disk path of a manifest given its snapshot id.
85    fn manifest_disk_path(&self, id: &str) -> PathBuf {
86        self.root.join(manifest_path(id))
87    }
88}
89
90impl Store for FileStore {
91    fn get_manifest(&self, id: &str) -> Result<Manifest, StoreError> {
92        let path = self.manifest_disk_path(id);
93        let bytes = match fs::read(&path) {
94            Ok(bytes) => bytes,
95            Err(err) if err.kind() == io::ErrorKind::NotFound => {
96                return Err(StoreError::ManifestNotFound { id: id.to_owned() });
97            }
98            Err(err) => return Err(StoreError::Io(err)),
99        };
100
101        // The snapshot id is BLAKE3 of the comment-stripped manifest text with
102        // the oracle's trailing `echo` newline. Verify the stored bytes hash
103        // back to `id` before trusting them (oracle: the manifest id check on
104        // fetch). `snapshot_id` in core re-renders + re-hashes the parsed
105        // manifest, so parse first, then verify against the parsed form.
106        let text = String::from_utf8(bytes).map_err(|err| StoreError::Backend {
107            message: format!("manifest {id} is not valid UTF-8"),
108            source: Some(Box::new(err)),
109        })?;
110        let manifest = Manifest::parse(&text)?;
111
112        let actual = snapdir_core::merkle::snapshot_id(&manifest, &Blake3Hasher::new());
113        if actual != id {
114            return Err(StoreError::Integrity {
115                address: manifest_path(id),
116                expected: id.to_owned(),
117                actual,
118            });
119        }
120
121        Ok(manifest)
122    }
123
124    fn fetch_files(&self, manifest: &Manifest, dest: &Path) -> Result<(), StoreError> {
125        let hasher = Blake3Hasher::new();
126        for entry in manifest.entries() {
127            let rel = strip_leading_dot_slash(&entry.path);
128            let target = dest.join(rel);
129            match entry.path_type {
130                PathType::Directory => {
131                    fs::create_dir_all(&target)?;
132                }
133                PathType::File => {
134                    if let Some(parent) = target.parent() {
135                        fs::create_dir_all(parent)?;
136                    }
137                    let source = self.object_disk_path(&entry.checksum);
138                    if !source.exists() {
139                        return Err(StoreError::ObjectNotFound {
140                            checksum: entry.checksum.clone(),
141                        });
142                    }
143                    persist(&source, &target, &entry.checksum, &hasher)?;
144                }
145            }
146        }
147        Ok(())
148    }
149
150    fn push(&self, manifest: &Manifest, source: &Path) -> Result<(), StoreError> {
151        // Compute the snapshot id of the manifest we are about to push so we
152        // can locate (and skip-if-present) its manifest file.
153        let hasher = Blake3Hasher::new();
154        let id = snapdir_core::merkle::snapshot_id(manifest, &hasher);
155        let manifest_target = self.manifest_disk_path(&id);
156
157        // Skip-if-present: nothing to do when the manifest already exists. A
158        // present manifest implies all its objects are present (we maintain
159        // that invariant by writing the manifest last).
160        if manifest_target.exists() {
161            return Ok(());
162        }
163
164        // Push every referenced object that is absent, BEFORE the manifest.
165        for entry in manifest.entries() {
166            if entry.path_type != PathType::File {
167                continue;
168            }
169            let object_target = self.object_disk_path(&entry.checksum);
170            if object_target.exists() {
171                // Skip-if-present per object: trust an object already filed
172                // under its content address (it is content-addressable).
173                continue;
174            }
175            let rel = strip_leading_dot_slash(&entry.path);
176            let object_source = source.join(rel);
177            persist(&object_source, &object_target, &entry.checksum, &hasher)?;
178        }
179
180        // Write the manifest last, via the same verify/retry/atomic-rename
181        // path, so a present manifest always implies present objects.
182        write_manifest(manifest, &manifest_target, &id, &hasher)?;
183        Ok(())
184    }
185}
186
187/// Copies `source` to `target`, verifying the content BLAKE3 against
188/// `expected`, retrying up to [`MAX_PERSIST_RETRIES`] times, then atomically
189/// renaming into place. Mirrors `_snapdir_file_store_persit`.
190fn persist(
191    source: &Path,
192    target: &Path,
193    expected: &str,
194    hasher: &impl Hasher,
195) -> Result<(), StoreError> {
196    if let Some(parent) = target.parent() {
197        fs::create_dir_all(parent)?;
198    }
199
200    let mut attempts_left = MAX_PERSIST_RETRIES;
201    loop {
202        // Copy to a unique temp path beside the target so the final rename is
203        // an atomic, same-filesystem move (the oracle's `.tmp` discipline).
204        let tmp = temp_sibling(target);
205        copy_file(source, &tmp)?;
206
207        let actual = hash_file(&tmp, hasher)?;
208        if actual == expected {
209            // Atomic rename into the final content-addressed location.
210            fs::rename(&tmp, target)?;
211            return Ok(());
212        }
213
214        // Copied bytes did not verify. Clean up the temp file and decide
215        // whether to retry: the oracle only retries when the *source* still
216        // hashes to the expected value, otherwise the source itself is bad.
217        let _ = fs::remove_file(&tmp);
218        let source_actual = hash_file(source, hasher)?;
219        if source_actual != expected {
220            return Err(StoreError::Integrity {
221                address: source.display().to_string(),
222                expected: expected.to_owned(),
223                actual: source_actual,
224            });
225        }
226
227        attempts_left = attempts_left.saturating_sub(1);
228        if attempts_left == 0 {
229            return Err(StoreError::Integrity {
230                address: target.display().to_string(),
231                expected: expected.to_owned(),
232                actual,
233            });
234        }
235    }
236}
237
238/// Writes a manifest's text to `target`, verifying it hashes to `id`, then
239/// atomically renaming into place. The manifest's "content" is the
240/// snapshot-id-bearing text (`Display` + trailing newline), so we verify with
241/// [`snapdir_core::merkle::snapshot_id`] rather than a raw byte hash.
242fn write_manifest(
243    manifest: &Manifest,
244    target: &Path,
245    id: &str,
246    hasher: &impl Hasher,
247) -> Result<(), StoreError> {
248    if let Some(parent) = target.parent() {
249        fs::create_dir_all(parent)?;
250    }
251
252    // The on-disk manifest must hash (snapshot_id) back to `id`. Render once
253    // and confirm before writing.
254    let actual = snapdir_core::merkle::snapshot_id(manifest, hasher);
255    if actual != id {
256        return Err(StoreError::Integrity {
257            address: target.display().to_string(),
258            expected: id.to_owned(),
259            actual,
260        });
261    }
262
263    // Oracle stores `echo "${manifest}"` — the manifest text plus a single
264    // trailing newline (the same bytes snapshot_id hashes).
265    let mut text = manifest.to_string();
266    text.push('\n');
267
268    let tmp = temp_sibling(target);
269    fs::write(&tmp, text.as_bytes())?;
270    fs::rename(&tmp, target)?;
271    Ok(())
272}
273
274/// Copies a regular file's bytes from `source` to `target` (mirrors the
275/// oracle's `cp -RL -n`: dereference, do not clobber — `target` is a fresh
276/// temp path so the no-clobber aspect is implicit).
277fn copy_file(source: &Path, target: &Path) -> Result<(), StoreError> {
278    fs::copy(source, target)?;
279    Ok(())
280}
281
282/// Hashes a file's full byte content with `hasher`.
283fn hash_file(path: &Path, hasher: &impl Hasher) -> Result<String, StoreError> {
284    let bytes = fs::read(path)?;
285    Ok(hasher.hash_hex(&bytes))
286}
287
288/// Builds a unique temp sibling path for `target` (same directory, so the
289/// final rename stays on one filesystem). Uses pid + a process-monotonic
290/// counter so concurrent persists never collide.
291fn temp_sibling(target: &Path) -> PathBuf {
292    use std::sync::atomic::{AtomicU64, Ordering};
293    static COUNTER: AtomicU64 = AtomicU64::new(0);
294    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
295    let pid = std::process::id();
296    let file_name = target
297        .file_name()
298        .map(|s| s.to_string_lossy().into_owned())
299        .unwrap_or_default();
300    let tmp_name = format!("{file_name}.{pid}.{n}.tmp");
301    match target.parent() {
302        Some(parent) => parent.join(tmp_name),
303        None => PathBuf::from(tmp_name),
304    }
305}
306
307/// Strips a leading `./` (relative-mode manifest paths) and a trailing `/`
308/// (directory entries) so the remainder can be joined onto a destination root.
309fn strip_leading_dot_slash(path: &str) -> &str {
310    let trimmed = path.strip_prefix("./").unwrap_or(path);
311    trimmed.strip_suffix('/').unwrap_or(trimmed)
312}
313
314/// Resolves a `store` URL/path to its on-disk directory, matching the oracle's
315/// `_snapdir_file_store_get_store_dir`:
316///
317/// ```sh
318/// store_dir="$(echo "$store" | sed -E 's|^file:/*(localhost/?)?|/|')"
319/// echo "${store_dir%/}"
320/// ```
321///
322/// i.e. replace a leading `file:` + any number of `/` (optionally followed by
323/// `localhost` + optional `/`) with a single `/`, then strip a trailing slash.
324fn parse_store_dir(store: &str) -> PathBuf {
325    let resolved = if let Some(rest) = store.strip_prefix("file:") {
326        // Drop the run of slashes the scheme leaves behind.
327        let rest = rest.trim_start_matches('/');
328        // An optional `localhost` host segment, with an optional trailing
329        // slash, is also dropped by the oracle's regex.
330        let rest = if let Some(after) = rest.strip_prefix("localhost") {
331            after.strip_prefix('/').unwrap_or(after)
332        } else {
333            rest
334        };
335        // The regex always substitutes a single leading `/`.
336        format!("/{rest}")
337    } else {
338        store.to_owned()
339    };
340
341    // `${store_dir%/}` — strip a single trailing slash (but keep a bare "/").
342    let trimmed = if resolved.len() > 1 {
343        resolved.strip_suffix('/').unwrap_or(&resolved)
344    } else {
345        &resolved
346    };
347    PathBuf::from(trimmed)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use snapdir_core::manifest::ManifestEntry;
354    use std::fs;
355    use std::path::Path;
356
357    // A tiny temp-dir helper so tests don't pull in a dev-dependency. Creates a
358    // unique directory under the system temp dir and removes it on drop.
359    struct TempDir {
360        path: PathBuf,
361    }
362
363    impl TempDir {
364        fn new(tag: &str) -> Self {
365            use std::sync::atomic::{AtomicU64, Ordering};
366            static COUNTER: AtomicU64 = AtomicU64::new(0);
367            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
368            let path = std::env::temp_dir().join(format!(
369                "snapdir-filestore-test-{}-{tag}-{n}",
370                std::process::id()
371            ));
372            fs::create_dir_all(&path).expect("create temp dir");
373            Self { path }
374        }
375
376        fn path(&self) -> &Path {
377            &self.path
378        }
379    }
380
381    impl Drop for TempDir {
382        fn drop(&mut self) {
383            let _ = fs::remove_dir_all(&self.path);
384        }
385    }
386
387    /// Builds a manifest for a source tree containing `foo` ("foo\n") and
388    /// `bar` ("bar\n") and writes those files into `source`. Returns the
389    /// manifest and its snapshot id. Checksums are the real BLAKE3 of the
390    /// file bytes so the store's verification passes.
391    fn make_foo_bar_source(source: &Path) -> (Manifest, String) {
392        let hasher = Blake3Hasher::new();
393        fs::write(source.join("foo"), b"foo\n").unwrap();
394        fs::write(source.join("bar"), b"bar\n").unwrap();
395        let foo_sum = hasher.hash_hex(b"foo\n");
396        let bar_sum = hasher.hash_hex(b"bar\n");
397
398        let root_sum =
399            snapdir_core::merkle::directory_checksum([foo_sum.as_str(), bar_sum.as_str()], &hasher);
400
401        let mut manifest = Manifest::new();
402        manifest.push(ManifestEntry::new(
403            PathType::Directory,
404            "700",
405            root_sum,
406            8,
407            "./",
408        ));
409        manifest.push(ManifestEntry::new(
410            PathType::File,
411            "600",
412            bar_sum,
413            4,
414            "./bar",
415        ));
416        manifest.push(ManifestEntry::new(
417            PathType::File,
418            "600",
419            foo_sum,
420            4,
421            "./foo",
422        ));
423        let manifest = Manifest::from_entries(manifest.entries().to_vec());
424        let id = snapdir_core::merkle::snapshot_id(&manifest, &hasher);
425        (manifest, id)
426    }
427
428    #[test]
429    fn file_store_parse_store_dir_matches_oracle_sed() {
430        // file:// + abs path -> abs path; trailing slash stripped.
431        assert_eq!(
432            parse_store_dir("file:///tmp/store"),
433            PathBuf::from("/tmp/store")
434        );
435        assert_eq!(
436            parse_store_dir("file:///tmp/store/"),
437            PathBuf::from("/tmp/store")
438        );
439        // localhost host segment dropped.
440        assert_eq!(
441            parse_store_dir("file://localhost/tmp/store"),
442            PathBuf::from("/tmp/store")
443        );
444        // file:// + abs path with two slashes.
445        assert_eq!(
446            parse_store_dir("file://tmp/store"),
447            PathBuf::from("/tmp/store")
448        );
449        // bare absolute path left intact.
450        assert_eq!(parse_store_dir("/tmp/store"), PathBuf::from("/tmp/store"));
451        // bare root preserved.
452        assert_eq!(parse_store_dir("file:///"), PathBuf::from("/"));
453    }
454
455    #[test]
456    fn file_store_push_lands_objects_at_sharded_keys_and_manifest_last() {
457        let store_dir = TempDir::new("store");
458        let src_dir = TempDir::new("src");
459        let (manifest, id) = make_foo_bar_source(src_dir.path());
460
461        let store = FileStore::from_root(store_dir.path());
462        store.push(&manifest, src_dir.path()).expect("push ok");
463
464        // Objects land at the exact sharded keys.
465        for entry in manifest.entries() {
466            if entry.path_type == PathType::File {
467                let obj = store_dir.path().join(object_path(&entry.checksum));
468                assert!(obj.exists(), "expected object at {}", obj.display());
469                // Content matches.
470                let bytes = fs::read(&obj).unwrap();
471                assert_eq!(
472                    Blake3Hasher::new().hash_hex(&bytes),
473                    entry.checksum,
474                    "object content must hash to its address"
475                );
476            }
477        }
478
479        // Manifest written at its sharded key, and hashes back to the id.
480        let man_path = store_dir.path().join(manifest_path(&id));
481        assert!(man_path.exists(), "manifest must exist after push");
482        let read_back = store.get_manifest(&id).expect("manifest reads back");
483        assert_eq!(read_back, manifest);
484    }
485
486    #[test]
487    fn file_store_push_skips_when_manifest_present() {
488        let store_dir = TempDir::new("store");
489        let src_dir = TempDir::new("src");
490        let (manifest, id) = make_foo_bar_source(src_dir.path());
491        let store = FileStore::from_root(store_dir.path());
492        store.push(&manifest, src_dir.path()).expect("first push");
493
494        // Remove an object but keep the manifest: a second push must skip
495        // entirely (manifest-present short-circuit), leaving the object gone.
496        let foo_entry = manifest
497            .entries()
498            .iter()
499            .find(|e| e.path == "./foo")
500            .unwrap();
501        let obj = store_dir.path().join(object_path(&foo_entry.checksum));
502        fs::remove_file(&obj).unwrap();
503
504        let _ = id;
505        store
506            .push(&manifest, src_dir.path())
507            .expect("second push skips");
508        assert!(
509            !obj.exists(),
510            "manifest-present push must be a full no-op (object stays removed)"
511        );
512    }
513
514    #[test]
515    fn file_store_push_skips_present_objects_but_adds_missing() {
516        let store_dir = TempDir::new("store");
517        let src_dir = TempDir::new("src");
518        let (manifest, id) = make_foo_bar_source(src_dir.path());
519        let store = FileStore::from_root(store_dir.path());
520        store.push(&manifest, src_dir.path()).expect("first push");
521
522        // Delete the manifest and one object; re-push must re-create the
523        // missing object (and the manifest) without erroring on the present one.
524        let man_path = store_dir.path().join(manifest_path(&id));
525        fs::remove_file(&man_path).unwrap();
526        let foo_entry = manifest
527            .entries()
528            .iter()
529            .find(|e| e.path == "./foo")
530            .unwrap();
531        let foo_obj = store_dir.path().join(object_path(&foo_entry.checksum));
532        fs::remove_file(&foo_obj).unwrap();
533
534        store.push(&manifest, src_dir.path()).expect("re-push");
535        assert!(foo_obj.exists(), "missing object must be re-added");
536        assert!(man_path.exists(), "manifest must be re-written");
537    }
538
539    #[test]
540    fn file_store_fetch_round_trips_and_verifies() {
541        let store_dir = TempDir::new("store");
542        let src_dir = TempDir::new("src");
543        let dest_dir = TempDir::new("dest");
544        let (manifest, id) = make_foo_bar_source(src_dir.path());
545        let store = FileStore::from_root(store_dir.path());
546        store.push(&manifest, src_dir.path()).expect("push");
547
548        let fetched = store.get_manifest(&id).expect("get manifest");
549        store
550            .fetch_files(&fetched, dest_dir.path())
551            .expect("fetch files");
552
553        assert_eq!(fs::read(dest_dir.path().join("foo")).unwrap(), b"foo\n");
554        assert_eq!(fs::read(dest_dir.path().join("bar")).unwrap(), b"bar\n");
555    }
556
557    #[test]
558    fn file_store_get_manifest_missing_is_not_found() {
559        let store_dir = TempDir::new("store");
560        let store = FileStore::from_root(store_dir.path());
561        let missing = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
562        match store.get_manifest(missing) {
563            Err(StoreError::ManifestNotFound { id }) => assert_eq!(id, missing),
564            other => panic!("expected ManifestNotFound, got {other:?}"),
565        }
566    }
567
568    #[test]
569    fn file_store_get_manifest_tampered_fails_integrity() {
570        let store_dir = TempDir::new("store");
571        let src_dir = TempDir::new("src");
572        let (manifest, id) = make_foo_bar_source(src_dir.path());
573        let store = FileStore::from_root(store_dir.path());
574        store.push(&manifest, src_dir.path()).expect("push");
575
576        // Tamper with the stored manifest bytes.
577        let man_path = store_dir.path().join(manifest_path(&id));
578        fs::write(&man_path, b"D 700 deadbeef 0 ./\n").unwrap();
579
580        match store.get_manifest(&id) {
581            Err(StoreError::Integrity { expected, .. }) => assert_eq!(expected, id),
582            other => panic!("expected Integrity, got {other:?}"),
583        }
584    }
585
586    #[test]
587    fn file_store_fetch_missing_object_is_not_found() {
588        let store_dir = TempDir::new("store");
589        let dest_dir = TempDir::new("dest");
590        let hasher = Blake3Hasher::new();
591        let foo_sum = hasher.hash_hex(b"foo\n");
592
593        let mut manifest = Manifest::new();
594        manifest.push(ManifestEntry::new(PathType::Directory, "700", "x", 4, "./"));
595        manifest.push(ManifestEntry::new(
596            PathType::File,
597            "600",
598            foo_sum.clone(),
599            4,
600            "./foo",
601        ));
602
603        let store = FileStore::from_root(store_dir.path());
604        match store.fetch_files(&manifest, dest_dir.path()) {
605            Err(StoreError::ObjectNotFound { checksum }) => assert_eq!(checksum, foo_sum),
606            other => panic!("expected ObjectNotFound, got {other:?}"),
607        }
608    }
609
610    #[test]
611    fn file_store_persist_rejects_corrupt_source() {
612        // A "source" object whose bytes do not match the claimed checksum must
613        // fail integrity (the oracle's "Invalid source checksum" path), not
614        // silently store corrupt data.
615        let store_dir = TempDir::new("store");
616        let src_dir = TempDir::new("src");
617        let dest_dir = TempDir::new("dest");
618        let hasher = Blake3Hasher::new();
619
620        // Real foo source/manifest, then corrupt the stored object so fetch's
621        // verify-on-copy trips and the source (the corrupt store object) fails.
622        let (manifest, id) = make_foo_bar_source(src_dir.path());
623        let store = FileStore::from_root(store_dir.path());
624        store.push(&manifest, src_dir.path()).expect("push");
625
626        let foo_entry = manifest
627            .entries()
628            .iter()
629            .find(|e| e.path == "./foo")
630            .unwrap();
631        let foo_obj = store_dir.path().join(object_path(&foo_entry.checksum));
632        fs::write(&foo_obj, b"corrupted not foo\n").unwrap();
633        // Sanity: the corrupted bytes really differ from the expected sum.
634        assert_ne!(hasher.hash_hex(b"corrupted not foo\n"), foo_entry.checksum);
635
636        let fetched = store.get_manifest(&id).expect("manifest still valid");
637        match store.fetch_files(&fetched, dest_dir.path()) {
638            Err(StoreError::Integrity { expected, .. }) => {
639                assert_eq!(expected, foo_entry.checksum);
640            }
641            other => panic!("expected Integrity from corrupt object, got {other:?}"),
642        }
643        // The corrupt object must NOT have been materialized at the dest.
644        assert!(!dest_dir.path().join("foo").exists());
645    }
646
647    #[test]
648    fn file_store_strip_leading_dot_slash() {
649        assert_eq!(strip_leading_dot_slash("./foo"), "foo");
650        assert_eq!(strip_leading_dot_slash("./a/b/c"), "a/b/c");
651        assert_eq!(strip_leading_dot_slash("./a/"), "a");
652        assert_eq!(strip_leading_dot_slash("./"), "");
653        assert_eq!(strip_leading_dot_slash("/abs/path"), "/abs/path");
654    }
655}