contract.rs raw

//! The behavioral contract every backend must satisfy, exercised as one
//! reusable suite.
//!
//! It always runs against `LocalBackend` (which validates the suite
//! itself). The remote backends run against real servers when pointed
//! at them via environment variables, and are skipped otherwise:
//!
//! ```text
//! BEEPING_TEST_FTP_URL=ftp://user:pass@localhost:2121/contract
//! BEEPING_TEST_SFTP_URL=sftp://user@localhost/tmp/contract
//! BEEPING_TEST_S3_URL='s3://bucket/contract?endpoint=http://localhost:9000&region=minio'
//! ```
//!
//! Each run uses fresh object ids, so a shared test server does not
//! need cleaning between runs.

use repository::{Backend, ChunkId, Error, LocalBackend, ObjectKey, ObjectKind, SnapshotId};

/// Distinct ids per run so the suite is self-isolating on shared
/// servers.
fn run_nonce() -> [u8; 8] {
    use std::time::{SystemTime, UNIX_EPOCH};

    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("the clock is past 1970")
        .subsec_nanos();

    let mut nonce = [0u8; 8];
    nonce[..4].copy_from_slice(&nanos.to_be_bytes());
    nonce[4..].copy_from_slice(&std::process::id().to_be_bytes());

    nonce
}

fn test_chunk_id(nonce: [u8; 8], variant: u8) -> ChunkId {
    let mut hex = String::new();

    for byte in nonce {
        hex.push_str(&format!("{byte:02x}"));
    }

    hex.push_str(&format!("{variant:02x}"));
    hex.push_str(&"00".repeat(32 - nonce.len() - 1));

    ChunkId::from_hex(hex).expect("constructed hex is valid")
}

fn test_snapshot_id(nonce: [u8; 8], variant: u8) -> SnapshotId {
    let mut hex = String::new();

    for byte in nonce {
        hex.push_str(&format!("{byte:02x}"));
    }

    hex.push_str(&format!("{variant:02x}"));
    hex.push_str(&"00".repeat(16 - nonce.len() - 1));

    SnapshotId::from_hex(hex).expect("constructed hex is valid")
}

/// The full backend contract: write-once puts, idempotency, get,
/// contains, list, and delete.
fn exercise(backend: &dyn Backend) {
    let nonce = run_nonce();

    let chunk_a = ObjectKey::Chunk(test_chunk_id(nonce, 1));
    let chunk_b = ObjectKey::Chunk(test_chunk_id(nonce, 2));
    let snapshot = ObjectKey::Snapshot(test_snapshot_id(nonce, 1));

    // Missing objects read as absent, not as errors
    assert!(!backend.contains(&chunk_a).unwrap());
    assert_eq!(backend.get(&chunk_a).unwrap(), None);

    // The single-segment header object goes first: that mirrors `init`
    // writing into a completely fresh repository, which is exactly the
    // case where a backend that only creates directories for deeper
    // objects falls over
    backend.put(&ObjectKey::Header, b"header payload").unwrap();
    assert_eq!(
        backend.get(&ObjectKey::Header).unwrap().as_deref(),
        Some(b"header payload".as_slice())
    );

    // Round trip
    backend.put(&chunk_a, b"chunk a payload").unwrap();
    assert!(backend.contains(&chunk_a).unwrap());
    assert_eq!(
        backend.get(&chunk_a).unwrap().as_deref(),
        Some(b"chunk a payload".as_slice())
    );

    // Write-once: a second put succeeds and the content stands
    backend.put(&chunk_a, b"chunk a payload").unwrap();
    assert_eq!(
        backend.get(&chunk_a).unwrap().as_deref(),
        Some(b"chunk a payload".as_slice())
    );

    // A larger, binary-unfriendly payload
    let big: Vec<u8> = (0..200_000u32).flat_map(|n| n.to_le_bytes()).collect();
    backend.put(&chunk_b, &big).unwrap();
    assert_eq!(backend.get(&chunk_b).unwrap().as_deref(), Some(&big[..]));

    // Snapshots live in their own namespace
    backend.put(&snapshot, b"snapshot record").unwrap();
    assert_eq!(
        backend.get(&snapshot).unwrap().as_deref(),
        Some(b"snapshot record".as_slice())
    );

    // Listing finds what this run stored (a shared server may hold
    // more)
    let mut chunks = Vec::new();
    backend
        .list(ObjectKind::Chunk, &mut |key| {
            chunks.push(key);
            Ok(())
        })
        .unwrap();
    assert!(chunks.contains(&chunk_a), "chunk a missing from listing");
    assert!(chunks.contains(&chunk_b), "chunk b missing from listing");

    let mut snapshots = Vec::new();
    backend
        .list(ObjectKind::Snapshot, &mut |key| {
            snapshots.push(key);
            Ok(())
        })
        .unwrap();
    assert!(snapshots.contains(&snapshot));

    // A visitor error propagates
    let result = backend.list(ObjectKind::Chunk, &mut |_| {
        Err(Error::Backend("stop".to_string()))
    });
    assert!(result.is_err());

    // Deletion, idempotently
    for key in [&chunk_a, &chunk_b, &snapshot, &ObjectKey::Header] {
        backend.delete(key).unwrap();
        backend.delete(key).unwrap();
        assert!(!backend.contains(key).unwrap());
        assert_eq!(backend.get(key).unwrap(), None);
    }
}

#[test]
fn local_backend_contract() {
    let dir = tempfile::tempdir().unwrap();

    exercise(&LocalBackend::new(dir.path().join("repo")).unwrap());
}

fn exercise_remote(env_var: &str) {
    let Ok(url) = std::env::var(env_var) else {
        eprintln!("{env_var} not set; skipping");
        return;
    };

    let url = url::Url::parse(&url).unwrap();
    let backend = backends::open_url(&url).unwrap();

    exercise(&*backend);
}

#[test]
fn ftp_backend_contract() {
    exercise_remote("BEEPING_TEST_FTP_URL");
}

#[test]
fn sftp_backend_contract() {
    exercise_remote("BEEPING_TEST_SFTP_URL");
}

#[test]
fn s3_backend_contract() {
    exercise_remote("BEEPING_TEST_S3_URL");
}