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®ion=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");
}