//! 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 = (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"); }