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);
    }
}