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
40const MAX_PERSIST_RETRIES: u32 = 5;
43
44#[derive(Debug, Clone)]
50pub struct FileStore {
51 root: PathBuf,
52}
53
54impl FileStore {
55 #[must_use]
63 pub fn new(store: &str) -> Self {
64 Self::from_root(parse_store_dir(store))
65 }
66
67 #[must_use]
69 pub fn from_root(root: impl Into<PathBuf>) -> Self {
70 Self { root: root.into() }
71 }
72
73 #[must_use]
75 pub fn root(&self) -> &Path {
76 &self.root
77 }
78
79 fn object_disk_path(&self, checksum: &str) -> PathBuf {
81 self.root.join(object_path(checksum))
82 }
83
84 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 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 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 if manifest_target.exists() {
161 return Ok(());
162 }
163
164 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 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_manifest(manifest, &manifest_target, &id, &hasher)?;
183 Ok(())
184 }
185}
186
187fn 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 let tmp = temp_sibling(target);
205 copy_file(source, &tmp)?;
206
207 let actual = hash_file(&tmp, hasher)?;
208 if actual == expected {
209 fs::rename(&tmp, target)?;
211 return Ok(());
212 }
213
214 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
238fn 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 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 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
274fn copy_file(source: &Path, target: &Path) -> Result<(), StoreError> {
278 fs::copy(source, target)?;
279 Ok(())
280}
281
282fn hash_file(path: &Path, hasher: &impl Hasher) -> Result<String, StoreError> {
284 let bytes = fs::read(path)?;
285 Ok(hasher.hash_hex(&bytes))
286}
287
288fn 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
307fn 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
314fn parse_store_dir(store: &str) -> PathBuf {
325 let resolved = if let Some(rest) = store.strip_prefix("file:") {
326 let rest = rest.trim_start_matches('/');
328 let rest = if let Some(after) = rest.strip_prefix("localhost") {
331 after.strip_prefix('/').unwrap_or(after)
332 } else {
333 rest
334 };
335 format!("/{rest}")
337 } else {
338 store.to_owned()
339 };
340
341 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 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 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 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 assert_eq!(
441 parse_store_dir("file://localhost/tmp/store"),
442 PathBuf::from("/tmp/store")
443 );
444 assert_eq!(
446 parse_store_dir("file://tmp/store"),
447 PathBuf::from("/tmp/store")
448 );
449 assert_eq!(parse_store_dir("/tmp/store"), PathBuf::from("/tmp/store"));
451 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 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 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 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 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 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 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 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 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 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 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}