tests.rs
raw
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<Repository, Error> {
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::<crate::crypto::Header, _>(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<Vec<u8>> = repo
.chunk_stream(content.as_slice())
.collect::<Result<_, _>>()
.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<Entry> {
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<Entry> = repo
.manifest_entries(manifest)
.collect::<Result<_, _>>()
.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<Entry> = (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<Entry> = repo
.manifest_entries(manifest.clone())
.collect::<Result<_, _>>()
.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<Entry> = repo
.manifest_entries(manifest)
.collect::<Result<_, _>>()
.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<Entry> = repo
.manifest_entries(loaded.manifest)
.collect::<Result<_, _>>()
.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<std::sync::atomic::AtomicUsize>,
}
impl Backend for Counting {
fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), Error> {
self.inner.put(key, data)
}
fn get(&self, key: &ObjectKey) -> Result<Option<Vec<u8>>, Error> {
self.inner.get(key)
}
fn contains(&self, key: &ObjectKey) -> Result<bool, Error> {
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<Vec<u8>> = (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);
}
}