use std::path::PathBuf; use super::*; fn test_repo(dir: &tempfile::TempDir) -> Repository { let backend = LocalBackend::new(dir.path().join("repo")).unwrap(); Repository::create(Box::new(backend), "test-password").unwrap() } fn reopen(dir: &tempfile::TempDir, password: &str) -> Result { let backend = LocalBackend::new(dir.path().join("repo")).unwrap(); Repository::open(Box::new(backend), password) } #[test] fn create_and_reopen() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let (id, new) = repo.store_chunk(b"some content").unwrap(); assert!(new); drop(repo); let repo = reopen(&dir, "test-password").unwrap(); assert_eq!(repo.load_chunk(id).unwrap(), b"some content"); // The reopened repository derives the same chunk ids let (same_id, new) = repo.store_chunk(b"some content").unwrap(); assert_eq!(same_id, id); assert!(!new); } #[test] fn wrong_password_is_rejected() { let dir = tempfile::tempdir().unwrap(); test_repo(&dir); assert!(matches!( reopen(&dir, "not-the-password"), Err(Error::WrongPassword) )); } #[test] fn header_tampering_is_rejected() { let dir = tempfile::tempdir().unwrap(); test_repo(&dir); // A storage-level attacker rewrites the public header. The verifier // commits to the header's decoded *content* — salt, KDF cost // parameters, chunker parameters — so any flip that changes what // the header means must make opening fail. (Some flips only change // the CBOR encoding, not the decoded value; those are harmless by // definition and opening may succeed.) let header_path = dir.path().join("repo/header"); let original = std::fs::read(&header_path).unwrap(); let decoded: crate::crypto::Header = ciborium::from_reader(original.as_slice()).unwrap(); let mut semantic_flips = 0; for index in 0..original.len() { let mut tampered = original.clone(); tampered[index] ^= 0x01; if matches!( ciborium::from_reader::(tampered.as_slice()), Ok(ref reread) if *reread == decoded ) { // Encoding-only malleability; nothing observable changed continue; } semantic_flips += 1; std::fs::write(&header_path, &tampered).unwrap(); assert!( reopen(&dir, "test-password").is_err(), "header byte {index} flipped the decoded content, but the \ repository still opened" ); } assert!( semantic_flips > original.len() / 2, "the malleability carve-out should be the exception, not the rule" ); std::fs::write(&header_path, &original).unwrap(); assert!(reopen(&dir, "test-password").is_ok()); } #[test] fn open_requires_initialization_and_create_refuses_it() { let dir = tempfile::tempdir().unwrap(); let backend = LocalBackend::new(dir.path().join("repo")).unwrap(); assert!(matches!( Repository::open(Box::new(backend), "pw"), Err(Error::NotInitialized) )); test_repo(&dir); let backend = LocalBackend::new(dir.path().join("repo")).unwrap(); assert!(matches!( Repository::create(Box::new(backend), "pw"), Err(Error::AlreadyInitialized) )); } #[test] fn chunks_deduplicate() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let (id_a, new_a) = repo.store_chunk(b"identical content").unwrap(); let (id_b, new_b) = repo.store_chunk(b"identical content").unwrap(); let (id_c, new_c) = repo.store_chunk(b"different content").unwrap(); assert_eq!(id_a, id_b); assert_ne!(id_a, id_c); assert!(new_a); assert!(!new_b); assert!(new_c); assert!(repo.contains_chunk(id_a).unwrap()); assert_eq!(repo.load_chunk(id_c).unwrap(), b"different content"); } #[test] fn missing_chunk_is_reported() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let absent = ChunkId::from_hex("ab".repeat(32)).unwrap(); assert!(!repo.contains_chunk(absent).unwrap()); assert!(matches!( repo.load_chunk(absent), Err(Error::MissingChunk(id)) if id == absent )); } #[test] fn chunk_swapping_is_detected() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let (id_a, _) = repo.store_chunk(b"content a").unwrap(); let (id_b, _) = repo.store_chunk(b"content b").unwrap(); // An attacker with access to the storage substitutes one encrypted // object for another; the AAD binding must catch it let sealed_b = repo .backend() .get(&ObjectKey::Chunk(id_b)) .unwrap() .unwrap(); repo.backend().delete(&ObjectKey::Chunk(id_a)).unwrap(); repo.backend() .put(&ObjectKey::Chunk(id_a), &sealed_b) .unwrap(); assert!(matches!(repo.load_chunk(id_a), Err(Error::Crypto))); } #[test] fn stored_objects_do_not_leak_plaintext() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let secret = b"extremely secret file content, definitely not for the storage host"; let (id, _) = repo.store_chunk(secret).unwrap(); let sealed = repo.backend().get(&ObjectKey::Chunk(id)).unwrap().unwrap(); assert!( !sealed .windows(b"secret".len()) .any(|window| window == b"secret"), "ciphertext must not contain the plaintext" ); } #[test] fn chunk_stream_splits_and_reassembles() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); // Deterministic pseudo-random content, comfortably larger than the // maximum chunk size so it must split let mut content = Vec::with_capacity(10 * 1024 * 1024); let mut state = 0x12345678u64; while content.len() < 10 * 1024 * 1024 { state = state .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); content.extend_from_slice(&state.to_le_bytes()); } let chunks: Vec> = repo .chunk_stream(content.as_slice()) .collect::>() .unwrap(); assert!(chunks.len() > 2, "10 MiB must split into several chunks"); let mut ids = Vec::new(); for chunk in &chunks { ids.push(repo.store_chunk(chunk).unwrap().0); } let mut reassembled = Vec::new(); for id in &ids { reassembled.extend_from_slice(&repo.load_chunk(*id).unwrap()); } assert_eq!(reassembled, content); } fn sample_entries() -> Vec { vec![ Entry { path: PathBuf::from("docs"), kind: EntryKind::Directory, mode: Some(0o755), mtime: Some(1_700_000_000_000_000_000), }, Entry { path: PathBuf::from("docs/notes.txt"), kind: EntryKind::File { chunks: vec![ChunkId::from_hex("11".repeat(32)).unwrap()], len: 42, }, mode: Some(0o644), mtime: Some(1_700_000_001_000_000_000), }, Entry { path: PathBuf::from("docs/link"), kind: EntryKind::Symlink { target: PathBuf::from("notes.txt"), }, mode: None, mtime: None, }, ] } #[test] fn manifest_roundtrip() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let entries = sample_entries(); let manifest = repo.store_manifest(entries.clone()).unwrap(); assert!(!manifest.is_empty()); let restored: Vec = repo .manifest_entries(manifest) .collect::>() .unwrap(); assert_eq!(restored, entries); } #[test] fn large_manifest_spans_chunks_and_deduplicates() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let entries: Vec = (0..20_000) .map(|index| Entry { path: PathBuf::from(format!("some/deeply/nested/directory/file-{index:08}.dat")), kind: EntryKind::File { chunks: vec![ChunkId::from_hex("22".repeat(32)).unwrap()], len: index, }, mode: Some(0o644), mtime: Some(index as i64), }) .collect(); let manifest = repo.store_manifest(entries.clone()).unwrap(); assert!( manifest.len() > 1, "20k entries should span multiple manifest chunks" ); let restored: Vec = repo .manifest_entries(manifest.clone()) .collect::>() .unwrap(); assert_eq!(restored, entries); // Storing the same manifest again produces the same chunks and // writes nothing new let again = repo.store_manifest(entries).unwrap(); assert_eq!(again, manifest); } #[test] fn empty_manifest_roundtrip() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let manifest = repo.store_manifest(Vec::new()).unwrap(); let restored: Vec = repo .manifest_entries(manifest) .collect::>() .unwrap(); assert!(restored.is_empty()); } /// Objects are write-once, so two snapshot records that make different /// claims about the same manifest must be two objects. A run whose last /// checkpoint drained every entry finishes with exactly that manifest, /// and the complete record it then stores must not be swallowed by the /// partial one already sitting there. #[test] fn a_partial_and_a_complete_snapshot_of_one_manifest_are_two_objects() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); let manifest = repo.store_manifest(sample_entries()).unwrap(); let root = PathBuf::from("/home/someone"); let partial = repo.snapshot_id(&root, &manifest, Coverage::Partial); let complete = repo.snapshot_id(&root, &manifest, Coverage::Complete); assert_ne!(partial, complete); repo.store_snapshot(&Snapshot::partial( partial, root.clone(), manifest.clone(), 3, 42, )) .unwrap(); repo.store_snapshot(&Snapshot::new(complete, root, manifest, 3, 42)) .unwrap(); assert_eq!( repo.load_snapshot(partial).unwrap().coverage, Coverage::Partial ); assert_eq!( repo.load_snapshot(complete).unwrap().coverage, Coverage::Complete ); } #[test] fn snapshot_roundtrip_and_listing() { let dir = tempfile::tempdir().unwrap(); let repo = test_repo(&dir); assert!(repo.snapshots().unwrap().is_empty()); let manifest = repo.store_manifest(sample_entries()).unwrap(); let root = PathBuf::from("/home/someone"); // The id derivation is deterministic, so a redone backup converges // on the same snapshot object let id = repo.snapshot_id(&root, &manifest, Coverage::Complete); assert_eq!(id, repo.snapshot_id(&root, &manifest, Coverage::Complete)); assert_ne!( id, repo.snapshot_id(&PathBuf::from("/other"), &manifest, Coverage::Complete) ); let snapshot = Snapshot::new(id, root, manifest, 3, 42); repo.store_snapshot(&snapshot).unwrap(); let listed = repo.snapshots().unwrap(); assert_eq!(listed, vec![snapshot.id]); let loaded = repo.load_snapshot(snapshot.id).unwrap(); assert_eq!(loaded.id, snapshot.id); assert_eq!(loaded.root, snapshot.root); assert_eq!(loaded.manifest, snapshot.manifest); assert_eq!(loaded.entries, 3); assert_eq!(loaded.content_bytes, 42); let restored: Vec = repo .manifest_entries(loaded.manifest) .collect::>() .unwrap(); assert_eq!(restored, sample_entries()); } #[test] fn backend_put_and_delete_are_idempotent() { let dir = tempfile::tempdir().unwrap(); let backend = LocalBackend::new(dir.path().join("repo")).unwrap(); let id = ChunkId::from_hex("cd".repeat(32)).unwrap(); let key = ObjectKey::Chunk(id); backend.put(&key, b"payload").unwrap(); backend.put(&key, b"payload").unwrap(); assert_eq!(backend.get(&key).unwrap().unwrap(), b"payload"); backend.delete(&key).unwrap(); backend.delete(&key).unwrap(); assert!(backend.get(&key).unwrap().is_none()); } #[test] fn backend_list_skips_foreign_files() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("repo"); let backend = LocalBackend::new(&root).unwrap(); let id = ChunkId::from_hex("ef".repeat(32)).unwrap(); backend.put(&ObjectKey::Chunk(id), b"payload").unwrap(); // Junk that could be left by an interrupted put std::fs::write(root.join("chunks/ef/.tmp12345"), b"junk").unwrap(); std::fs::create_dir_all(root.join("snapshots")).unwrap(); std::fs::write(root.join("snapshots/.tmp99999"), b"junk").unwrap(); let mut chunks = Vec::new(); backend .list(ObjectKind::Chunk, &mut |key| { chunks.push(key); Ok(()) }) .unwrap(); assert_eq!(chunks, vec![ObjectKey::Chunk(id)]); let mut snapshots = Vec::new(); backend .list(ObjectKind::Snapshot, &mut |key| { snapshots.push(key); Ok(()) }) .unwrap(); assert!(snapshots.is_empty()); } /// Counts what a repository asks its backend, so the chunk cache can be /// shown to spare the asking. The tally is shared rather than reachable /// through the trait: a backend is not otherwise inspectable, and adding /// a way to downcast one for a test's sake would be worse than this. struct Counting { inner: LocalBackend, contains: std::sync::Arc, } impl Backend for Counting { fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), Error> { self.inner.put(key, data) } fn get(&self, key: &ObjectKey) -> Result>, Error> { self.inner.get(key) } fn contains(&self, key: &ObjectKey) -> Result { self.contains .fetch_add(1, std::sync::atomic::Ordering::AcqRel); self.inner.contains(key) } fn list( &self, kind: ObjectKind, visit: &mut dyn FnMut(ObjectKey) -> Result<(), Error>, ) -> Result<(), Error> { self.inner.list(kind, visit) } fn delete(&self, key: &ObjectKey) -> Result<(), Error> { self.inner.delete(key) } } /// Every chunk considered costs a round trip to ask whether the /// repository already holds it, which over a remote link is the dominant /// cost of redoing work. A cache answers locally for the chunks the /// backend has already confirmed. #[test] fn a_chunk_cache_spares_the_backend_the_asking() { use std::sync::{Arc, atomic::AtomicUsize, atomic::Ordering}; let dir = tempfile::tempdir().unwrap(); let chunks: Vec> = (0..20u8).map(|seed| vec![seed; 4096]).collect(); let store_all = |repository: &Repository| { for chunk in &chunks { repository.store_chunk(chunk).unwrap(); } }; let tally = Arc::new(AtomicUsize::new(0)); let backend = Counting { inner: LocalBackend::new(dir.path().join("repo")).unwrap(), contains: tally.clone(), }; let mut repository = Repository::create(Box::new(backend), "test-password").unwrap(); // Without a cache, a second pass asks all over again store_all(&repository); let after_first = tally.load(Ordering::Acquire); store_all(&repository); assert!( tally.load(Ordering::Acquire) > after_first, "without a cache the backend is asked about every chunk, every time" ); // With one attached, what the backend has already confirmed is never // asked about again let identity = repository.cache_identity().unwrap(); repository.attach_chunk_cache(ChunkCache::open(dir.path().join("cache"), identity).unwrap()); store_all(&repository); let learned = repository.chunk_cache().unwrap().len(); let before = tally.load(Ordering::Acquire); store_all(&repository); assert_eq!( tally.load(Ordering::Acquire), before, "a pass over known chunks must not touch the backend at all" ); assert_eq!(learned, chunks.len(), "the cache learned every chunk"); // The cache outlives the process that built it, under a name that // belongs to this repository let fingerprint = repository.fingerprint(); drop(repository); let backend = Counting { inner: LocalBackend::new(dir.path().join("repo")).unwrap(), contains: tally.clone(), }; let mut reopened = Repository::open(Box::new(backend), "test-password").unwrap(); assert_eq!(reopened.fingerprint(), fingerprint, "a stable cache name"); let identity = reopened.cache_identity().unwrap(); reopened.attach_chunk_cache(ChunkCache::open(dir.path().join("cache"), identity).unwrap()); assert_eq!(reopened.chunk_cache().unwrap().len(), chunks.len()); // And the content is still all there, cache or no cache for chunk in &chunks { let (id, new) = reopened.store_chunk(chunk).unwrap(); assert!(!new, "the cache must not lose track of stored content"); assert_eq!(&reopened.load_chunk(id).unwrap(), chunk); } }