1use 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
40use crate::transfer::TransferConfig;
41use crate::util::{file_present_and_verified, hash_file};
42
43const MAX_PERSIST_RETRIES: u32 = 5;
46
47#[derive(Debug, Clone)]
53pub struct FileStore {
54 root: PathBuf,
55 config: TransferConfig,
56}
57
58impl FileStore {
59 #[must_use]
67 pub fn new(store: &str) -> Self {
68 Self::from_root(parse_store_dir(store))
69 }
70
71 #[must_use]
74 pub fn new_with_config(store: &str, config: TransferConfig) -> Self {
75 Self::from_root_with_config(parse_store_dir(store), config)
76 }
77
78 #[must_use]
80 pub fn from_root(root: impl Into<PathBuf>) -> Self {
81 Self::from_root_with_config(root, TransferConfig::default())
82 }
83
84 #[must_use]
88 pub fn from_root_with_config(root: impl Into<PathBuf>, config: TransferConfig) -> Self {
89 Self {
90 root: root.into(),
91 config,
92 }
93 }
94
95 #[must_use]
97 pub fn root(&self) -> &Path {
98 &self.root
99 }
100
101 #[must_use]
104 pub fn transfer_config(&self) -> &TransferConfig {
105 &self.config
106 }
107
108 fn object_disk_path(&self, checksum: &str) -> PathBuf {
110 self.root.join(object_path(checksum))
111 }
112
113 fn manifest_disk_path(&self, id: &str) -> PathBuf {
115 self.root.join(manifest_path(id))
116 }
117
118 fn parallel_copy(&self, jobs: &[(PathBuf, PathBuf, String)]) -> Result<(), StoreError> {
128 use rayon::prelude::*;
129
130 if jobs.is_empty() {
131 return Ok(());
132 }
133
134 let pool = rayon::ThreadPoolBuilder::new()
135 .num_threads(self.config.concurrency.get())
136 .build()
137 .map_err(|err| StoreError::Backend {
138 message: "failed to build copy thread pool".to_owned(),
139 source: Some(Box::new(err)),
140 })?;
141
142 pool.install(|| {
143 jobs.par_iter().try_for_each(|(source, target, expected)| {
144 persist(source, target, expected, &Blake3Hasher::new())
145 })
146 })
147 }
148}
149
150impl Store for FileStore {
151 fn get_manifest(&self, id: &str) -> Result<Manifest, StoreError> {
152 let path = self.manifest_disk_path(id);
153 let bytes = match fs::read(&path) {
154 Ok(bytes) => bytes,
155 Err(err) if err.kind() == io::ErrorKind::NotFound => {
156 return Err(StoreError::ManifestNotFound { id: id.to_owned() });
157 }
158 Err(err) => return Err(StoreError::Io(err)),
159 };
160
161 let text = String::from_utf8(bytes).map_err(|err| StoreError::Backend {
167 message: format!("manifest {id} is not valid UTF-8"),
168 source: Some(Box::new(err)),
169 })?;
170 let manifest = Manifest::parse(&text)?;
171
172 let actual = snapdir_core::merkle::snapshot_id(&manifest, &Blake3Hasher::new());
173 if actual != id {
174 return Err(StoreError::Integrity {
175 address: manifest_path(id),
176 expected: id.to_owned(),
177 actual,
178 });
179 }
180
181 Ok(manifest)
182 }
183
184 fn fetch_files(&self, manifest: &Manifest, dest: &Path) -> Result<(), StoreError> {
185 let hasher = Blake3Hasher::new();
186
187 let mut jobs: Vec<(PathBuf, PathBuf, String)> = Vec::new();
197 for entry in manifest.entries() {
198 let rel = strip_leading_dot_slash(&entry.path);
199 let target = dest.join(rel);
200 match entry.path_type {
201 PathType::Directory => {
202 fs::create_dir_all(&target)?;
203 }
204 PathType::File => {
205 if file_present_and_verified(&target, &entry.checksum, &hasher) {
210 continue;
211 }
212 if let Some(parent) = target.parent() {
213 fs::create_dir_all(parent)?;
214 }
215 let source = self.object_disk_path(&entry.checksum);
216 if !source.exists() {
217 return Err(StoreError::ObjectNotFound {
218 checksum: entry.checksum.clone(),
219 });
220 }
221 jobs.push((source, target, entry.checksum.clone()));
222 }
223 }
224 }
225
226 self.parallel_copy(&jobs)
230 }
231
232 fn push(&self, manifest: &Manifest, source: &Path) -> Result<(), StoreError> {
233 let hasher = Blake3Hasher::new();
236 let id = snapdir_core::merkle::snapshot_id(manifest, &hasher);
237 let manifest_target = self.manifest_disk_path(&id);
238
239 if manifest_target.exists() {
243 return Ok(());
244 }
245
246 let mut jobs: Vec<(PathBuf, PathBuf, String)> = Vec::new();
250 for entry in manifest.entries() {
251 if entry.path_type != PathType::File {
252 continue;
253 }
254 let object_target = self.object_disk_path(&entry.checksum);
255 if object_target.exists() {
256 continue;
257 }
258 let rel = strip_leading_dot_slash(&entry.path);
259 let object_source = source.join(rel);
260 jobs.push((object_source, object_target, entry.checksum.clone()));
261 }
262
263 self.parallel_copy(&jobs)?;
268
269 write_manifest(manifest, &manifest_target, &id, &hasher)?;
272 Ok(())
273 }
274}
275
276fn persist(
280 source: &Path,
281 target: &Path,
282 expected: &str,
283 hasher: &impl Hasher,
284) -> Result<(), StoreError> {
285 if let Some(parent) = target.parent() {
286 fs::create_dir_all(parent)?;
287 }
288
289 let mut attempts_left = MAX_PERSIST_RETRIES;
290 loop {
291 let tmp = temp_sibling(target);
294 copy_file(source, &tmp)?;
295
296 let actual = hash_file(&tmp, hasher)?;
297 if actual == expected {
298 fs::rename(&tmp, target)?;
300 return Ok(());
301 }
302
303 let _ = fs::remove_file(&tmp);
307 let source_actual = hash_file(source, hasher)?;
308 if source_actual != expected {
309 return Err(StoreError::Integrity {
310 address: source.display().to_string(),
311 expected: expected.to_owned(),
312 actual: source_actual,
313 });
314 }
315
316 attempts_left = attempts_left.saturating_sub(1);
317 if attempts_left == 0 {
318 return Err(StoreError::Integrity {
319 address: target.display().to_string(),
320 expected: expected.to_owned(),
321 actual,
322 });
323 }
324 }
325}
326
327fn write_manifest(
332 manifest: &Manifest,
333 target: &Path,
334 id: &str,
335 hasher: &impl Hasher,
336) -> Result<(), StoreError> {
337 if let Some(parent) = target.parent() {
338 fs::create_dir_all(parent)?;
339 }
340
341 let actual = snapdir_core::merkle::snapshot_id(manifest, hasher);
344 if actual != id {
345 return Err(StoreError::Integrity {
346 address: target.display().to_string(),
347 expected: id.to_owned(),
348 actual,
349 });
350 }
351
352 let mut text = manifest.to_string();
355 text.push('\n');
356
357 let tmp = temp_sibling(target);
358 fs::write(&tmp, text.as_bytes())?;
359 fs::rename(&tmp, target)?;
360 Ok(())
361}
362
363fn copy_file(source: &Path, target: &Path) -> Result<(), StoreError> {
367 fs::copy(source, target)?;
368 Ok(())
369}
370
371fn temp_sibling(target: &Path) -> PathBuf {
375 use std::sync::atomic::{AtomicU64, Ordering};
376 static COUNTER: AtomicU64 = AtomicU64::new(0);
377 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
378 let pid = std::process::id();
379 let file_name = target
380 .file_name()
381 .map(|s| s.to_string_lossy().into_owned())
382 .unwrap_or_default();
383 let tmp_name = format!("{file_name}.{pid}.{n}.tmp");
384 match target.parent() {
385 Some(parent) => parent.join(tmp_name),
386 None => PathBuf::from(tmp_name),
387 }
388}
389
390fn strip_leading_dot_slash(path: &str) -> &str {
393 let trimmed = path.strip_prefix("./").unwrap_or(path);
394 trimmed.strip_suffix('/').unwrap_or(trimmed)
395}
396
397fn parse_store_dir(store: &str) -> PathBuf {
408 let resolved = if let Some(rest) = store.strip_prefix("file:") {
409 let rest = rest.trim_start_matches('/');
411 let rest = if let Some(after) = rest.strip_prefix("localhost") {
414 after.strip_prefix('/').unwrap_or(after)
415 } else {
416 rest
417 };
418 format!("/{rest}")
420 } else {
421 store.to_owned()
422 };
423
424 let trimmed = if resolved.len() > 1 {
426 resolved.strip_suffix('/').unwrap_or(&resolved)
427 } else {
428 &resolved
429 };
430 PathBuf::from(trimmed)
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use snapdir_core::manifest::ManifestEntry;
437 use std::fs;
438 use std::path::Path;
439
440 struct TempDir {
443 path: PathBuf,
444 }
445
446 impl TempDir {
447 fn new(tag: &str) -> Self {
448 use std::sync::atomic::{AtomicU64, Ordering};
449 static COUNTER: AtomicU64 = AtomicU64::new(0);
450 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
451 let path = std::env::temp_dir().join(format!(
452 "snapdir-filestore-test-{}-{tag}-{n}",
453 std::process::id()
454 ));
455 fs::create_dir_all(&path).expect("create temp dir");
456 Self { path }
457 }
458
459 fn path(&self) -> &Path {
460 &self.path
461 }
462 }
463
464 impl Drop for TempDir {
465 fn drop(&mut self) {
466 let _ = fs::remove_dir_all(&self.path);
467 }
468 }
469
470 fn make_foo_bar_source(source: &Path) -> (Manifest, String) {
475 let hasher = Blake3Hasher::new();
476 fs::write(source.join("foo"), b"foo\n").unwrap();
477 fs::write(source.join("bar"), b"bar\n").unwrap();
478 let foo_sum = hasher.hash_hex(b"foo\n");
479 let bar_sum = hasher.hash_hex(b"bar\n");
480
481 let root_sum =
482 snapdir_core::merkle::directory_checksum([foo_sum.as_str(), bar_sum.as_str()], &hasher);
483
484 let mut manifest = Manifest::new();
485 manifest.push(ManifestEntry::new(
486 PathType::Directory,
487 "700",
488 root_sum,
489 8,
490 "./",
491 ));
492 manifest.push(ManifestEntry::new(
493 PathType::File,
494 "600",
495 bar_sum,
496 4,
497 "./bar",
498 ));
499 manifest.push(ManifestEntry::new(
500 PathType::File,
501 "600",
502 foo_sum,
503 4,
504 "./foo",
505 ));
506 let manifest = Manifest::from_entries(manifest.entries().to_vec());
507 let id = snapdir_core::merkle::snapshot_id(&manifest, &hasher);
508 (manifest, id)
509 }
510
511 #[test]
512 fn file_store_parse_store_dir_matches_oracle_sed() {
513 assert_eq!(
515 parse_store_dir("file:///tmp/store"),
516 PathBuf::from("/tmp/store")
517 );
518 assert_eq!(
519 parse_store_dir("file:///tmp/store/"),
520 PathBuf::from("/tmp/store")
521 );
522 assert_eq!(
524 parse_store_dir("file://localhost/tmp/store"),
525 PathBuf::from("/tmp/store")
526 );
527 assert_eq!(
529 parse_store_dir("file://tmp/store"),
530 PathBuf::from("/tmp/store")
531 );
532 assert_eq!(parse_store_dir("/tmp/store"), PathBuf::from("/tmp/store"));
534 assert_eq!(parse_store_dir("file:///"), PathBuf::from("/"));
536 }
537
538 #[test]
539 fn file_store_push_lands_objects_at_sharded_keys_and_manifest_last() {
540 let store_dir = TempDir::new("store");
541 let src_dir = TempDir::new("src");
542 let (manifest, id) = make_foo_bar_source(src_dir.path());
543
544 let store = FileStore::from_root(store_dir.path());
545 store.push(&manifest, src_dir.path()).expect("push ok");
546
547 for entry in manifest.entries() {
549 if entry.path_type == PathType::File {
550 let obj = store_dir.path().join(object_path(&entry.checksum));
551 assert!(obj.exists(), "expected object at {}", obj.display());
552 let bytes = fs::read(&obj).unwrap();
554 assert_eq!(
555 Blake3Hasher::new().hash_hex(&bytes),
556 entry.checksum,
557 "object content must hash to its address"
558 );
559 }
560 }
561
562 let man_path = store_dir.path().join(manifest_path(&id));
564 assert!(man_path.exists(), "manifest must exist after push");
565 let read_back = store.get_manifest(&id).expect("manifest reads back");
566 assert_eq!(read_back, manifest);
567 }
568
569 #[test]
570 fn file_store_push_skips_when_manifest_present() {
571 let store_dir = TempDir::new("store");
572 let src_dir = TempDir::new("src");
573 let (manifest, id) = make_foo_bar_source(src_dir.path());
574 let store = FileStore::from_root(store_dir.path());
575 store.push(&manifest, src_dir.path()).expect("first push");
576
577 let foo_entry = manifest
580 .entries()
581 .iter()
582 .find(|e| e.path == "./foo")
583 .unwrap();
584 let obj = store_dir.path().join(object_path(&foo_entry.checksum));
585 fs::remove_file(&obj).unwrap();
586
587 let _ = id;
588 store
589 .push(&manifest, src_dir.path())
590 .expect("second push skips");
591 assert!(
592 !obj.exists(),
593 "manifest-present push must be a full no-op (object stays removed)"
594 );
595 }
596
597 #[test]
598 fn file_store_push_skips_present_objects_but_adds_missing() {
599 let store_dir = TempDir::new("store");
600 let src_dir = TempDir::new("src");
601 let (manifest, id) = make_foo_bar_source(src_dir.path());
602 let store = FileStore::from_root(store_dir.path());
603 store.push(&manifest, src_dir.path()).expect("first push");
604
605 let man_path = store_dir.path().join(manifest_path(&id));
608 fs::remove_file(&man_path).unwrap();
609 let foo_entry = manifest
610 .entries()
611 .iter()
612 .find(|e| e.path == "./foo")
613 .unwrap();
614 let foo_obj = store_dir.path().join(object_path(&foo_entry.checksum));
615 fs::remove_file(&foo_obj).unwrap();
616
617 store.push(&manifest, src_dir.path()).expect("re-push");
618 assert!(foo_obj.exists(), "missing object must be re-added");
619 assert!(man_path.exists(), "manifest must be re-written");
620 }
621
622 #[test]
623 fn file_store_fetch_round_trips_and_verifies() {
624 let store_dir = TempDir::new("store");
625 let src_dir = TempDir::new("src");
626 let dest_dir = TempDir::new("dest");
627 let (manifest, id) = make_foo_bar_source(src_dir.path());
628 let store = FileStore::from_root(store_dir.path());
629 store.push(&manifest, src_dir.path()).expect("push");
630
631 let fetched = store.get_manifest(&id).expect("get manifest");
632 store
633 .fetch_files(&fetched, dest_dir.path())
634 .expect("fetch files");
635
636 assert_eq!(fs::read(dest_dir.path().join("foo")).unwrap(), b"foo\n");
637 assert_eq!(fs::read(dest_dir.path().join("bar")).unwrap(), b"bar\n");
638 }
639
640 #[test]
641 fn file_store_get_manifest_missing_is_not_found() {
642 let store_dir = TempDir::new("store");
643 let store = FileStore::from_root(store_dir.path());
644 let missing = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
645 match store.get_manifest(missing) {
646 Err(StoreError::ManifestNotFound { id }) => assert_eq!(id, missing),
647 other => panic!("expected ManifestNotFound, got {other:?}"),
648 }
649 }
650
651 #[test]
652 fn file_store_get_manifest_tampered_fails_integrity() {
653 let store_dir = TempDir::new("store");
654 let src_dir = TempDir::new("src");
655 let (manifest, id) = make_foo_bar_source(src_dir.path());
656 let store = FileStore::from_root(store_dir.path());
657 store.push(&manifest, src_dir.path()).expect("push");
658
659 let man_path = store_dir.path().join(manifest_path(&id));
661 fs::write(&man_path, b"D 700 deadbeef 0 ./\n").unwrap();
662
663 match store.get_manifest(&id) {
664 Err(StoreError::Integrity { expected, .. }) => assert_eq!(expected, id),
665 other => panic!("expected Integrity, got {other:?}"),
666 }
667 }
668
669 #[test]
670 fn file_store_fetch_missing_object_is_not_found() {
671 let store_dir = TempDir::new("store");
672 let dest_dir = TempDir::new("dest");
673 let hasher = Blake3Hasher::new();
674 let foo_sum = hasher.hash_hex(b"foo\n");
675
676 let mut manifest = Manifest::new();
677 manifest.push(ManifestEntry::new(PathType::Directory, "700", "x", 4, "./"));
678 manifest.push(ManifestEntry::new(
679 PathType::File,
680 "600",
681 foo_sum.clone(),
682 4,
683 "./foo",
684 ));
685
686 let store = FileStore::from_root(store_dir.path());
687 match store.fetch_files(&manifest, dest_dir.path()) {
688 Err(StoreError::ObjectNotFound { checksum }) => assert_eq!(checksum, foo_sum),
689 other => panic!("expected ObjectNotFound, got {other:?}"),
690 }
691 }
692
693 #[test]
694 fn file_store_persist_rejects_corrupt_source() {
695 let store_dir = TempDir::new("store");
699 let src_dir = TempDir::new("src");
700 let dest_dir = TempDir::new("dest");
701 let hasher = Blake3Hasher::new();
702
703 let (manifest, id) = make_foo_bar_source(src_dir.path());
706 let store = FileStore::from_root(store_dir.path());
707 store.push(&manifest, src_dir.path()).expect("push");
708
709 let foo_entry = manifest
710 .entries()
711 .iter()
712 .find(|e| e.path == "./foo")
713 .unwrap();
714 let foo_obj = store_dir.path().join(object_path(&foo_entry.checksum));
715 fs::write(&foo_obj, b"corrupted not foo\n").unwrap();
716 assert_ne!(hasher.hash_hex(b"corrupted not foo\n"), foo_entry.checksum);
718
719 let fetched = store.get_manifest(&id).expect("manifest still valid");
720 match store.fetch_files(&fetched, dest_dir.path()) {
721 Err(StoreError::Integrity { expected, .. }) => {
722 assert_eq!(expected, foo_entry.checksum);
723 }
724 other => panic!("expected Integrity from corrupt object, got {other:?}"),
725 }
726 assert!(!dest_dir.path().join("foo").exists());
728 }
729
730 #[test]
731 fn fetch_skip_present_verified() {
732 let store_dir = TempDir::new("store");
737 let src_dir = TempDir::new("src");
738 let dest_dir = TempDir::new("dest");
739 let (manifest, id) = make_foo_bar_source(src_dir.path());
740
741 let store = FileStore::from_root(store_dir.path());
742 store.push(&manifest, src_dir.path()).expect("push");
743
744 let fetched = store.get_manifest(&id).expect("get manifest");
745 store
746 .fetch_files(&fetched, dest_dir.path())
747 .expect("first fetch populates dest");
748 assert_eq!(fs::read(dest_dir.path().join("foo")).unwrap(), b"foo\n");
749 assert_eq!(fs::read(dest_dir.path().join("bar")).unwrap(), b"bar\n");
750
751 let objects = store_dir.path().join(".objects");
753 fs::remove_dir_all(&objects).expect("remove .objects tree");
754 assert!(!objects.exists());
755
756 store
759 .fetch_files(&fetched, dest_dir.path())
760 .expect("second fetch skips every present+verified file (no object reads)");
761
762 assert_eq!(fs::read(dest_dir.path().join("foo")).unwrap(), b"foo\n");
764 assert_eq!(fs::read(dest_dir.path().join("bar")).unwrap(), b"bar\n");
765 }
766
767 #[test]
768 fn file_store_fetch_repairs_corrupt_dest_and_skips_intact() {
769 let store_dir = TempDir::new("store");
773 let src_dir = TempDir::new("src");
774 let dest_dir = TempDir::new("dest");
775 let (manifest, id) = make_foo_bar_source(src_dir.path());
776
777 let store = FileStore::from_root(store_dir.path());
778 store.push(&manifest, src_dir.path()).expect("push");
779 let fetched = store.get_manifest(&id).expect("get manifest");
780 store
781 .fetch_files(&fetched, dest_dir.path())
782 .expect("first fetch populates dest");
783
784 fs::write(dest_dir.path().join("foo"), b"WRONG\n").unwrap();
786 let bar_entry = manifest
789 .entries()
790 .iter()
791 .find(|e| e.path == "./bar")
792 .unwrap();
793 let bar_obj = store_dir.path().join(object_path(&bar_entry.checksum));
794 fs::remove_file(&bar_obj).unwrap();
795
796 store
797 .fetch_files(&fetched, dest_dir.path())
798 .expect("repair corrupt foo, skip intact bar");
799
800 assert_eq!(fs::read(dest_dir.path().join("foo")).unwrap(), b"foo\n");
802 assert_eq!(fs::read(dest_dir.path().join("bar")).unwrap(), b"bar\n");
803 }
804
805 #[test]
806 fn file_store_fetch_mismatch_then_missing_object_errors() {
807 let store_dir = TempDir::new("store");
811 let src_dir = TempDir::new("src");
812 let dest_dir = TempDir::new("dest");
813 let (manifest, id) = make_foo_bar_source(src_dir.path());
814
815 let store = FileStore::from_root(store_dir.path());
816 store.push(&manifest, src_dir.path()).expect("push");
817 let fetched = store.get_manifest(&id).expect("get manifest");
818 store
819 .fetch_files(&fetched, dest_dir.path())
820 .expect("first fetch populates dest");
821
822 let foo_entry = manifest
823 .entries()
824 .iter()
825 .find(|e| e.path == "./foo")
826 .unwrap();
827 fs::write(dest_dir.path().join("foo"), b"WRONG\n").unwrap();
829 let foo_obj = store_dir.path().join(object_path(&foo_entry.checksum));
831 fs::remove_file(&foo_obj).unwrap();
832
833 match store.fetch_files(&fetched, dest_dir.path()) {
834 Err(StoreError::ObjectNotFound { checksum }) => {
835 assert_eq!(checksum, foo_entry.checksum);
836 }
837 other => panic!("expected ObjectNotFound (cannot repair), got {other:?}"),
838 }
839 }
840
841 fn make_nested_source(source: &Path) -> (Manifest, String) {
854 let hasher = Blake3Hasher::new();
855 let files: &[(&str, &[u8])] = &[
856 ("a.txt", b"a contents\n"),
857 ("b.txt", b"b contents\n"),
858 ("sub/c.txt", b"c contents\n"),
859 ("sub/deep/d.txt", b"d contents\n"),
860 ];
861
862 fs::create_dir_all(source.join("sub/deep")).unwrap();
863 for (rel, bytes) in files {
864 fs::write(source.join(rel), bytes).unwrap();
865 }
866
867 let mut manifest = Manifest::new();
868 manifest.push(ManifestEntry::new(PathType::Directory, "700", "x", 0, "./"));
873 manifest.push(ManifestEntry::new(
874 PathType::Directory,
875 "700",
876 "x",
877 0,
878 "./sub/",
879 ));
880 manifest.push(ManifestEntry::new(
881 PathType::Directory,
882 "700",
883 "x",
884 0,
885 "./sub/deep/",
886 ));
887 for (rel, bytes) in files {
888 let sum = hasher.hash_hex(bytes);
889 #[allow(clippy::cast_possible_truncation)]
890 manifest.push(ManifestEntry::new(
891 PathType::File,
892 "600",
893 sum,
894 bytes.len() as u64,
895 format!("./{rel}"),
896 ));
897 }
898
899 let manifest = Manifest::from_entries(manifest.entries().to_vec());
900 let id = snapdir_core::merkle::snapshot_id(&manifest, &hasher);
901 (manifest, id)
902 }
903
904 fn assert_nested_dest(dest: &Path) {
906 assert_eq!(fs::read(dest.join("a.txt")).unwrap(), b"a contents\n");
907 assert_eq!(fs::read(dest.join("b.txt")).unwrap(), b"b contents\n");
908 assert_eq!(fs::read(dest.join("sub/c.txt")).unwrap(), b"c contents\n");
909 assert_eq!(
910 fs::read(dest.join("sub/deep/d.txt")).unwrap(),
911 b"d contents\n"
912 );
913 }
914
915 #[test]
916 fn filestore_parallel_roundtrip_byte_identical() {
917 let src_dir = TempDir::new("src");
921 let (manifest, id) = make_nested_source(src_dir.path());
922
923 let par_store_dir = TempDir::new("store-par");
925 let par_dest_dir = TempDir::new("dest-par");
926 let par_store =
927 FileStore::from_root_with_config(par_store_dir.path(), TransferConfig::new(4, None));
928 par_store.push(&manifest, src_dir.path()).expect("par push");
929 let par_manifest = par_store.get_manifest(&id).expect("par get manifest");
930 assert_eq!(par_manifest, manifest, "round-tripped manifest matches");
931 par_store
932 .fetch_files(&par_manifest, par_dest_dir.path())
933 .expect("par fetch");
934 assert_nested_dest(par_dest_dir.path());
935
936 let seq_store_dir = TempDir::new("store-seq");
938 let seq_dest_dir = TempDir::new("dest-seq");
939 let seq_store =
940 FileStore::from_root_with_config(seq_store_dir.path(), TransferConfig::new(1, None));
941 seq_store.push(&manifest, src_dir.path()).expect("seq push");
942 let seq_id = snapdir_core::merkle::snapshot_id(&manifest, &Blake3Hasher::new());
943 assert_eq!(seq_id, id, "snapshot id is concurrency-independent");
944 seq_store
945 .fetch_files(&manifest, seq_dest_dir.path())
946 .expect("seq fetch");
947 assert_nested_dest(seq_dest_dir.path());
948
949 for entry in manifest.entries() {
952 if entry.path_type != PathType::File {
953 continue;
954 }
955 let key = object_path(&entry.checksum);
956 let par_obj = par_store_dir.path().join(&key);
957 let seq_obj = seq_store_dir.path().join(&key);
958 assert!(par_obj.exists(), "par object {key} present");
959 assert!(seq_obj.exists(), "seq object {key} present");
960 assert_eq!(
961 fs::read(&par_obj).unwrap(),
962 fs::read(&seq_obj).unwrap(),
963 "par and seq object bytes identical"
964 );
965 }
966 }
967
968 #[test]
969 fn filestore_parallel_concurrency_one_sequential() {
970 let store_dir = TempDir::new("store");
973 let src_dir = TempDir::new("src");
974 let dest_dir = TempDir::new("dest");
975 let (manifest, id) = make_nested_source(src_dir.path());
976
977 let store =
978 FileStore::from_root_with_config(store_dir.path(), TransferConfig::new(1, None));
979 store.push(&manifest, src_dir.path()).expect("push");
980 let fetched = store.get_manifest(&id).expect("get manifest");
981 store.fetch_files(&fetched, dest_dir.path()).expect("fetch");
982 assert_nested_dest(dest_dir.path());
983 }
984
985 #[test]
986 fn filestore_parallel_all_or_nothing_bad_object() {
987 let store_dir = TempDir::new("store");
991 let src_dir = TempDir::new("src");
992 let (manifest, id) = make_nested_source(src_dir.path());
993
994 fs::write(src_dir.path().join("sub/c.txt"), b"TAMPERED\n").unwrap();
997
998 let store =
999 FileStore::from_root_with_config(store_dir.path(), TransferConfig::new(4, None));
1000 match store.push(&manifest, src_dir.path()) {
1001 Err(StoreError::Integrity { .. }) => {}
1002 other => panic!("expected Integrity from bad source object, got {other:?}"),
1003 }
1004
1005 let man_path = store.manifest_disk_path(&id);
1007 assert!(
1008 !man_path.exists(),
1009 "manifest must not be written when an object copy fails"
1010 );
1011 }
1012
1013 #[test]
1014 fn filestore_parallel_large_n_round_trips() {
1015 let store_dir = TempDir::new("store");
1017 let src_dir = TempDir::new("src");
1018 let dest_dir = TempDir::new("dest");
1019 let hasher = Blake3Hasher::new();
1020
1021 let mut manifest = Manifest::new();
1022 manifest.push(ManifestEntry::new(PathType::Directory, "700", "x", 0, "./"));
1023 let n = 50usize;
1024 for i in 0..n {
1025 let name = format!("file-{i:03}.txt");
1026 let contents = format!("contents of file {i}\n");
1027 fs::write(src_dir.path().join(&name), contents.as_bytes()).unwrap();
1028 let sum = hasher.hash_hex(contents.as_bytes());
1029 #[allow(clippy::cast_possible_truncation)]
1030 manifest.push(ManifestEntry::new(
1031 PathType::File,
1032 "600",
1033 sum,
1034 contents.len() as u64,
1035 format!("./{name}"),
1036 ));
1037 }
1038 let manifest = Manifest::from_entries(manifest.entries().to_vec());
1039 let id = snapdir_core::merkle::snapshot_id(&manifest, &hasher);
1040
1041 let store =
1042 FileStore::from_root_with_config(store_dir.path(), TransferConfig::new(4, None));
1043 store.push(&manifest, src_dir.path()).expect("push N files");
1044 let fetched = store.get_manifest(&id).expect("get manifest");
1045 store
1046 .fetch_files(&fetched, dest_dir.path())
1047 .expect("fetch N files");
1048
1049 for i in 0..n {
1050 let name = format!("file-{i:03}.txt");
1051 let expected = format!("contents of file {i}\n");
1052 assert_eq!(
1053 fs::read(dest_dir.path().join(&name)).unwrap(),
1054 expected.as_bytes()
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn file_store_strip_leading_dot_slash() {
1061 assert_eq!(strip_leading_dot_slash("./foo"), "foo");
1062 assert_eq!(strip_leading_dot_slash("./a/b/c"), "a/b/c");
1063 assert_eq!(strip_leading_dot_slash("./a/"), "a");
1064 assert_eq!(strip_leading_dot_slash("./"), "");
1065 assert_eq!(strip_leading_dot_slash("/abs/path"), "/abs/path");
1066 }
1067}