use super::*; use crate::analysis::timing::{TimingHandle, TimingMeta}; use crate::current_repo_index::CurrentRepoIndex; use crate::storage::RocksStore; use std::collections::HashMap; use std::io::Read; use std::time::Duration; struct MapFetcher { map: HashMap>, } impl Fetcher for MapFetcher { fn fetch(&self, uri: &str) -> Result, String> { self.map .get(uri) .cloned() .ok_or_else(|| format!("not found: {uri}")) } } struct SleepyFetcher { inner: MapFetcher, sleep_uri: String, sleep: Duration, } impl Fetcher for SleepyFetcher { fn fetch(&self, uri: &str) -> Result, String> { if uri == self.sleep_uri { std::thread::sleep(self.sleep); } self.inner.fetch(uri) } } struct WriterOnlyFetcher { map: HashMap>, } impl Fetcher for WriterOnlyFetcher { fn fetch(&self, uri: &str) -> Result, String> { Err(format!("unexpected buffered fetch: {uri}")) } fn fetch_to_writer(&self, uri: &str, out: &mut dyn std::io::Write) -> Result { let bytes = self .map .get(uri) .ok_or_else(|| format!("not found: {uri}"))?; out.write_all(bytes) .map_err(|e| format!("write sink failed: {e}"))?; Ok(bytes.len() as u64) } } struct NonAsciiWriterFetcher; impl Fetcher for NonAsciiWriterFetcher { fn fetch(&self, uri: &str) -> Result, String> { Err(format!("unexpected buffered fetch: {uri}")) } fn fetch_to_writer(&self, _uri: &str, out: &mut dyn std::io::Write) -> Result { out.write_all(&[0x80]) .map_err(|e| format!("write sink failed: {e}"))?; Err("snapshot body contains non-ASCII bytes".to_string()) } } fn assert_current_object(store: &RocksStore, uri: &str, expected: &[u8]) { assert_eq!( store .load_current_object_bytes_by_uri(uri) .expect("load current object"), Some(expected.to_vec()) ); } fn notification_xml( session_id: &str, serial: u64, snapshot_uri: &str, snapshot_hash: &str, ) -> Vec { format!( r#""# ) .into_bytes() } fn notification_xml_with_deltas( session_id: &str, serial: u64, snapshot_uri: &str, snapshot_hash: &str, deltas: &[(&str, u64, &str, &str)], ) -> Vec { let mut out = format!( r#""# ); for (_name, delta_serial, uri, hash) in deltas { out.push_str(&format!( r#""# )); } out.push_str(""); out.into_bytes() } fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec { let mut out = format!( r#""# ); for (uri, bytes) in published { let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); out.push_str(&format!(r#"{b64}"#)); } out.push_str(""); out.into_bytes() } #[test] fn fetch_snapshot_into_tempfile_streams_and_validates_hash() { let snapshot_uri = "https://example.test/snapshot.xml"; let snapshot = b"".to_vec(); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(&sha2::Sha256::digest(&snapshot)); let fetcher = WriterOnlyFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]), }; let (mut file, bytes_written) = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash) .expect("fetch snapshot into tempfile"); assert_eq!(bytes_written, snapshot.len() as u64); let mut got = Vec::new(); file.as_file_mut() .read_to_end(&mut got) .expect("read tempfile"); assert_eq!(got, snapshot); let mut wrong_hash = expected_hash; wrong_hash[0] ^= 0xff; let err = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch) )); } #[test] fn fetch_snapshot_into_tempfile_maps_stream_non_ascii_error() { let err = fetch_snapshot_into_tempfile( &NonAsciiWriterFetcher, "https://example.test/snapshot.xml", &[0u8; 32], ) .unwrap_err(); assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::NotAscii))); } #[test] fn timing_rrdp_repo_step_spans_cover_snapshot_fetch_duration() { let temp = tempfile::tempdir().expect("tempdir"); let store_dir = temp.path().join("db"); let store = RocksStore::open(&store_dir).expect("open rocksdb"); let notification_uri = "https://example.test/notification.xml"; let snapshot_uri = "https://example.test/snapshot.xml"; let published_uri = "rsync://example.test/repo/a.mft"; let published_bytes = b"x"; let session_id = "550e8400-e29b-41d4-a716-446655440000"; let snapshot = snapshot_xml(session_id, 1, &[(published_uri, published_bytes)]); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(session_id, 1, snapshot_uri, &snapshot_hash); let mut map = HashMap::new(); map.insert(snapshot_uri.to_string(), snapshot); let fetcher = SleepyFetcher { inner: MapFetcher { map }, sleep_uri: snapshot_uri.to_string(), sleep: Duration::from_millis(25), }; let timing = TimingHandle::new(TimingMeta { recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(), validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(), tal_url: None, db_path: Some(store_dir.to_string_lossy().into_owned()), }); sync_from_notification_snapshot_with_timing( &store, notification_uri, ¬if, &fetcher, Some(&timing), ) .expect("rrdp snapshot sync ok"); let timing_path = temp.path().join("timing.json"); timing.write_json(&timing_path, 200).expect("write timing"); let rep: serde_json::Value = serde_json::from_slice(&std::fs::read(&timing_path).expect("read timing")) .expect("parse timing"); let want = format!("{notification_uri}::fetch_snapshot"); let steps = rep .get("top_rrdp_repo_steps") .and_then(|v| v.as_array()) .expect("top_rrdp_repo_steps array"); let entry = steps .iter() .find(|e| e.get("key").and_then(|k| k.as_str()) == Some(want.as_str())) .unwrap_or_else(|| panic!("missing timing step entry for {want}")); let nanos = entry .get("total_nanos") .and_then(|v| v.as_u64()) .expect("total_nanos"); assert!( nanos >= 20_000_000, "expected fetch_snapshot timing to include the fetch duration; got {nanos}ns" ); } #[test] fn parse_notification_snapshot_rejects_non_ascii() { let mut xml = b"".to_vec(); xml.push(0x80); let err = parse_notification_snapshot(&xml).unwrap_err(); assert!(matches!(err, RrdpError::NotAscii)); } #[test] fn parse_notification_snapshot_parses_valid_minimal_notification() { let sid = "550e8400-e29b-41d4-a716-446655440000"; let snapshot_uri = "https://example.net/snapshot.xml"; let hash = "00".repeat(32); let xml = notification_xml(sid, 7, snapshot_uri, &hash); let n = parse_notification_snapshot(&xml).expect("parse"); assert_eq!(n.session_id, Uuid::parse_str(sid).unwrap()); assert_eq!(n.serial, 7); assert_eq!(n.snapshot_uri, snapshot_uri); assert_eq!(hex::encode(n.snapshot_hash_sha256), hash); } #[test] fn parse_notification_parses_deltas_and_validates_contiguity() { let sid = "550e8400-e29b-41d4-a716-446655440000"; let snapshot_uri = "https://example.net/snapshot.xml"; let hash = "00".repeat(32); let d_hash_2 = "11".repeat(32); let d_hash_3 = "22".repeat(32); // Provide deltas in reverse order to ensure we sort. let xml = notification_xml_with_deltas( sid, 3, snapshot_uri, &hash, &[ ("d3", 3, "https://example.net/delta-3.xml", &d_hash_3), ("d2", 2, "https://example.net/delta-2.xml", &d_hash_2), ], ); let n = parse_notification(&xml).expect("parse notification"); assert_eq!(n.serial, 3); assert_eq!(n.deltas.len(), 2); assert_eq!(n.deltas[0].serial, 2); assert_eq!(n.deltas[1].serial, 3); assert_eq!(n.deltas[0].uri, "https://example.net/delta-2.xml"); assert_eq!(hex::encode(n.deltas[1].hash_sha256), d_hash_3); } #[test] fn parse_notification_rejects_non_contiguous_deltas() { let sid = "550e8400-e29b-41d4-a716-446655440000"; let snapshot_uri = "https://example.net/snapshot.xml"; let hash = "00".repeat(32); let d_hash_1 = "11".repeat(32); let d_hash_3 = "22".repeat(32); // Missing delta serial 2. let xml = notification_xml_with_deltas( sid, 3, snapshot_uri, &hash, &[ ("d3", 3, "https://example.net/delta-3.xml", &d_hash_3), ("d1", 1, "https://example.net/delta-1.xml", &d_hash_1), ], ); let err = parse_notification(&xml).unwrap_err(); assert!(matches!(err, RrdpError::DeltaRefChainNotContiguous { .. })); } fn delta_xml(session_id: &str, serial: u64, elements: &[&str]) -> Vec { let mut out = format!( r#""# ); for e in elements { out.push_str(e); } out.push_str(""); out.into_bytes() } #[test] fn parse_delta_file_parses_publish_and_withdraw() { let sid = "550e8400-e29b-41d4-a716-446655440000"; let serial = 3u64; let publish_bytes = b"abc"; let publish_b64 = base64::engine::general_purpose::STANDARD.encode(publish_bytes); let withdraw_hash = "33".repeat(32); let xml = delta_xml( sid, serial, &[ &format!(r#"{publish_b64}"#), &format!(r#""#), ], ); let d = parse_delta_file(&xml).expect("parse delta"); assert_eq!(d.session_id, Uuid::parse_str(sid).unwrap()); assert_eq!(d.serial, serial); assert_eq!(d.elements.len(), 2); match &d.elements[0] { DeltaElement::Publish { uri, hash_sha256, bytes, } => { assert_eq!(uri, "rsync://example.net/repo/a.mft"); assert_eq!(*hash_sha256, None); assert_eq!(bytes, publish_bytes); } _ => panic!("expected publish"), } match &d.elements[1] { DeltaElement::Withdraw { uri, hash_sha256 } => { assert_eq!(uri, "rsync://example.net/repo/b.cer"); assert_eq!(hex::encode(hash_sha256), withdraw_hash); } _ => panic!("expected withdraw"), } } #[test] fn parse_delta_file_rejects_withdraw_with_content() { let sid = "550e8400-e29b-41d4-a716-446655440000"; let serial = 1u64; let withdraw_hash = "33".repeat(32); let xml = delta_xml( sid, serial, &[&format!( r#"AA=="# )], ); let err = parse_delta_file(&xml).unwrap_err(); assert!(matches!(err, RrdpError::DeltaWithdrawUnexpectedContent)); } #[test] fn apply_delta_applies_publish_replace_and_withdraw_with_membership_checks() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // Start from snapshot state with a + b let snapshot_uri = "https://example.net/snapshot.xml"; let snapshot = snapshot_xml( sid, 1, &[ ("rsync://example.net/repo/a.mft", b"a1"), ("rsync://example.net/repo/b.roa", b"b1"), ], ); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher).expect("sync snapshot"); let old_b = store .load_current_object_bytes_by_uri("rsync://example.net/repo/b.roa") .expect("load current b") .expect("b present"); let old_b_hash = hex::encode(sha2::Sha256::digest(old_b.as_slice())); let withdraw_a_hash = hex::encode(sha2::Sha256::digest(b"a1".as_slice())); let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2"); let replace_b_b64 = base64::engine::general_purpose::STANDARD.encode(b"b2"); let delta = delta_xml( sid, 2, &[ &format!( r#""# ), &format!( r#"{replace_b_b64}"# ), &format!(r#"{publish_c_b64}"#), ], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); let applied = apply_delta( &store, notif_uri, None, &delta, expected_hash, Uuid::parse_str(sid).unwrap(), 2, ) .expect("apply delta"); assert_eq!(applied, 3); assert_eq!( store .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft") .expect("load current a"), None, "a withdrawn" ); let b = store .load_current_object_bytes_by_uri("rsync://example.net/repo/b.roa") .expect("load current b") .expect("b present"); assert_eq!(b, b"b2"); let c = store .load_current_object_bytes_by_uri("rsync://example.net/repo/c.crl") .expect("load current c") .expect("c present"); assert_eq!(c, b"c2"); assert!( !store .is_current_rrdp_source_member(notif_uri, "rsync://example.net/repo/a.mft") .expect("is member"), "a removed from rrdp repo index" ); assert!( store .is_current_rrdp_source_member(notif_uri, "rsync://example.net/repo/c.crl") .expect("is member"), "c added to rrdp repo index" ); let a_view = store .get_repository_view_entry("rsync://example.net/repo/a.mft") .expect("get a view") .expect("a view exists"); assert_eq!(a_view.state, crate::storage::RepositoryViewState::Withdrawn); let b_view = store .get_repository_view_entry("rsync://example.net/repo/b.roa") .expect("get b view") .expect("b view exists"); assert_eq!(b_view.state, crate::storage::RepositoryViewState::Present); assert_eq!( b_view.current_hash.as_deref(), Some(hex::encode(sha2::Sha256::digest(b"b2")).as_str()) ); let c_owner = store .get_rrdp_uri_owner_record("rsync://example.net/repo/c.crl") .expect("get c owner") .expect("c owner exists"); assert_eq!( c_owner.owner_state, crate::storage::RrdpUriOwnerState::Active ); let a_member = store .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft") .expect("get a member") .expect("a member exists"); assert!(!a_member.present); let current_members = store .list_current_rrdp_source_members(notif_uri) .expect("list current members"); assert_eq!( current_members .iter() .map(|record| record.rsync_uri.as_str()) .collect::>(), vec![ "rsync://example.net/repo/b.roa", "rsync://example.net/repo/c.crl", ] ); } #[test] fn apply_delta_rejects_hash_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let notif_uri = "https://example.net/notification.xml"; let delta = delta_xml( sid.to_string().as_str(), 1, &[r#"QQ=="#], ); let mut wrong = [0u8; 32]; wrong[0] = 1; let err = apply_delta(&store, notif_uri, None, &delta, wrong, sid, 1).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaHashMismatch) )); } #[test] fn apply_delta_rejects_withdraw_of_non_member() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let notif_uri = "https://example.net/notification.xml"; let withdraw_hash = "00".repeat(32); let delta = delta_xml( sid.to_string().as_str(), 1, &[&format!( r#""# )], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); let err = apply_delta(&store, notif_uri, None, &delta, expected_hash, sid, 1).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaTargetNotFromRepository { .. }) )); } #[test] fn apply_delta_rejects_publish_without_hash_for_existing_object() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // Seed snapshot with a.mft. let snapshot_uri = "https://example.net/snapshot.xml"; let snapshot = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher).expect("seed"); // Replace publish for an existing URI must have @hash. let publish_b64 = base64::engine::general_purpose::STANDARD.encode(b"a2"); let delta = delta_xml( sid, 2, &[&format!( r#"{publish_b64}"# )], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); let err = apply_delta( &store, notif_uri, None, &delta, expected_hash, Uuid::parse_str(sid).unwrap(), 2, ) .unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaPublishWithoutHashForExisting { .. }) )); } #[test] fn apply_delta_rejects_target_missing_and_hash_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // Seed snapshot with a.mft. let snapshot_uri = "https://example.net/snapshot.xml"; let snapshot = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher).expect("seed"); let old_bytes = store .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft") .expect("get") .expect("present"); let old_hash = hex::encode(sha2::Sha256::digest(old_bytes.as_slice())); // Hash mismatch on withdraw. let wrong_hash = "11".repeat(32); let delta = delta_xml( sid, 2, &[&format!( r#""# )], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); let err = apply_delta( &store, notif_uri, None, &delta, expected_hash, Uuid::parse_str(sid).unwrap(), 2, ) .unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaTargetHashMismatch { .. }) )); // Target missing in local cache (index still says it's a member). store .delete_repository_view_entry("rsync://example.net/repo/a.mft") .expect("delete current repository view entry"); let delta = delta_xml( sid, 2, &[&format!( r#""# )], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); let err = apply_delta( &store, notif_uri, None, &delta, expected_hash, Uuid::parse_str(sid).unwrap(), 2, ) .unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaTargetMissing { .. }) )); } #[test] fn apply_delta_rejects_session_and_serial_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; let publish_b64 = base64::engine::general_purpose::STANDARD.encode(b"x"); let delta = delta_xml( sid, 2, &[&format!( r#"{publish_b64}"# )], ); let delta_hash = sha2::Sha256::digest(&delta); let mut expected_hash = [0u8; 32]; expected_hash.copy_from_slice(delta_hash.as_slice()); // Session mismatch. let other_sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(); let err = apply_delta(&store, notif_uri, None, &delta, expected_hash, other_sid, 2).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaSessionIdMismatch { .. }) )); // Serial mismatch. let err = apply_delta( &store, notif_uri, None, &delta, expected_hash, Uuid::parse_str(sid).unwrap(), 3, ) .unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::DeltaSerialMismatch { .. }) )); } #[test] fn sync_from_notification_snapshot_rejects_cross_source_owner_conflict() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid_a = "550e8400-e29b-41d4-a716-446655440000"; let sid_b = "550e8400-e29b-41d4-a716-446655440001"; let uri = "rsync://example.net/repo/a.mft"; let notif_a_uri = "https://example.net/a/notification.xml"; let snapshot_a_uri = "https://example.net/a/snapshot.xml"; let snapshot_a = snapshot_xml(sid_a, 1, &[(uri, b"a1")]); let snapshot_a_hash = hex::encode(sha2::Sha256::digest(&snapshot_a)); let notif_a = notification_xml(sid_a, 1, snapshot_a_uri, &snapshot_a_hash); let fetcher_a = MapFetcher { map: HashMap::from([(snapshot_a_uri.to_string(), snapshot_a)]), }; sync_from_notification_snapshot(&store, notif_a_uri, ¬if_a, &fetcher_a) .expect("seed source a"); let notif_b_uri = "https://example.net/b/notification.xml"; let snapshot_b_uri = "https://example.net/b/snapshot.xml"; let snapshot_b = snapshot_xml(sid_b, 1, &[(uri, b"b1")]); let snapshot_b_hash = hex::encode(sha2::Sha256::digest(&snapshot_b)); let notif_b = notification_xml(sid_b, 1, snapshot_b_uri, &snapshot_b_hash); let fetcher_b = MapFetcher { map: HashMap::from([(snapshot_b_uri.to_string(), snapshot_b)]), }; let err = sync_from_notification_snapshot(&store, notif_b_uri, ¬if_b, &fetcher_b) .expect_err("cross-source overwrite must fail"); assert!(matches!(err, RrdpSyncError::Storage(_))); assert!(err.to_string().contains("owner conflict"), "{err}"); } #[test] fn sync_from_notification_snapshot_applies_snapshot_and_stores_state() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let serial = 9u64; let notif_uri = "https://example.net/notification.xml"; let snapshot_uri = "https://example.net/snapshot.xml"; let snapshot = snapshot_xml( sid, serial, &[ ("rsync://example.net/repo/a.mft", b"mft-bytes"), ("rsync://example.net/repo/b.roa", b"roa-bytes"), ], ); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(sid, serial, snapshot_uri, &snapshot_hash); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]), }; let published = sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher).expect("sync"); assert_eq!(published, 2); assert_current_object(&store, "rsync://example.net/repo/a.mft", b"mft-bytes"); assert_current_object(&store, "rsync://example.net/repo/b.roa", b"roa-bytes"); let state = load_rrdp_local_state(&store, notif_uri) .expect("get rrdp state") .expect("state present"); assert_eq!(state.session_id, sid); assert_eq!(state.serial, serial); let source = store .get_rrdp_source_record(notif_uri) .expect("get rrdp source") .expect("rrdp source exists"); assert_eq!(source.last_session_id.as_deref(), Some(sid)); assert_eq!(source.last_serial, Some(serial)); assert_eq!( source.sync_state, crate::storage::RrdpSourceSyncState::SnapshotOnly ); let view = store .get_repository_view_entry("rsync://example.net/repo/a.mft") .expect("get repository view") .expect("repository view exists"); assert_eq!(view.state, crate::storage::RepositoryViewState::Present); assert_eq!(view.repository_source.as_deref(), Some(notif_uri)); let current_bytes = store .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft") .expect("load current bytes") .expect("current object bytes exist"); assert_eq!(current_bytes, b"mft-bytes".to_vec()); assert!( store .get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"mft-bytes")).as_str()) .expect("get raw_by_hash") .is_none() ); let member = store .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft") .expect("get member") .expect("member exists"); assert!(member.present); let owner = store .get_rrdp_uri_owner_record("rsync://example.net/repo/a.mft") .expect("get owner") .expect("owner exists"); assert_eq!(owner.notify_uri, notif_uri); assert_eq!(owner.owner_state, crate::storage::RrdpUriOwnerState::Active); } #[test] fn sync_from_notification_snapshot_deletes_objects_not_in_new_snapshot() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // serial 1: publish a + b let snapshot_uri_1 = "https://example.net/snapshot-1.xml"; let snapshot_1 = snapshot_xml( sid, 1, &[ ("rsync://example.net/repo/a.mft", b"a1"), ("rsync://example.net/repo/b.roa", b"b1"), ], ); let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1)); let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1); let fetcher_1 = MapFetcher { map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if_1, &fetcher_1).expect("sync 1"); // serial 2: publish b (new bytes) + c, and drop a let snapshot_uri_2 = "https://example.net/snapshot-2.xml"; let snapshot_2 = snapshot_xml( sid, 2, &[ ("rsync://example.net/repo/b.roa", b"b2"), ("rsync://example.net/repo/c.crl", b"c2"), ], ); let snapshot_hash_2 = hex::encode(sha2::Sha256::digest(&snapshot_2)); let notif_2 = notification_xml(sid, 2, snapshot_uri_2, &snapshot_hash_2); let fetcher_2 = MapFetcher { map: HashMap::from([(snapshot_uri_2.to_string(), snapshot_2)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if_2, &fetcher_2).expect("sync 2"); assert!( store .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft") .expect("get current a") .is_none(), "a should be deleted by full-state snapshot apply" ); assert_current_object(&store, "rsync://example.net/repo/b.roa", b"b2"); assert_current_object(&store, "rsync://example.net/repo/c.crl", b"c2"); } #[test] fn sync_from_notification_uses_deltas_when_available_for_local_state() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // Seed state with snapshot serial=1 containing a+b. let snapshot_uri_1 = "https://example.net/snapshot-1.xml"; let snapshot_1 = snapshot_xml( sid, 1, &[ ("rsync://example.net/repo/a.mft", b"a1"), ("rsync://example.net/repo/b.roa", b"b1"), ], ); let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1)); let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1); let fetcher_1 = MapFetcher { map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if_1, &fetcher_1).expect("seed"); // Notification serial=3 with deltas 2 and 3. Snapshot URI is intentionally not fetchable // to assert we really use deltas. let snapshot_uri_3 = "https://example.net/snapshot-3.xml"; let snapshot_hash_3 = "00".repeat(32); let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2"); let delta_2 = delta_xml( sid, 2, &[&format!( r#"{publish_c_b64}"# )], ); let delta_2_hash_hex = hex::encode(sha2::Sha256::digest(&delta_2)); let c_hash_hex = hex::encode(sha2::Sha256::digest(b"c2".as_slice())); let delta_3 = delta_xml( sid, 3, &[&format!( r#""# )], ); let delta_3_hash_hex = hex::encode(sha2::Sha256::digest(&delta_3)); let notif_3 = notification_xml_with_deltas( sid, 3, snapshot_uri_3, &snapshot_hash_3, &[ ( "d3", 3, "https://example.net/delta-3.xml", &delta_3_hash_hex, ), ( "d2", 2, "https://example.net/delta-2.xml", &delta_2_hash_hex, ), ], ); let fetcher = MapFetcher { map: HashMap::from([ ("https://example.net/delta-2.xml".to_string(), delta_2), ("https://example.net/delta-3.xml".to_string(), delta_3), ]), }; let applied = sync_from_notification(&store, notif_uri, ¬if_3, &fetcher).expect("sync"); assert!(applied > 0); // Delta 2 publishes c then delta 3 withdraws it => final state should not contain c. assert!( store .load_current_object_bytes_by_uri("rsync://example.net/repo/c.crl") .expect("get current") .is_none() ); let state = load_rrdp_local_state(&store, notif_uri) .expect("get rrdp state") .expect("state present"); assert_eq!(state.session_id, Uuid::parse_str(sid).unwrap().to_string()); assert_eq!(state.serial, 3); } #[test] fn sync_from_notification_same_serial_hydrates_current_repo_index() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; let snapshot_uri = "https://example.net/snapshot.xml"; let uri_a = "rsync://example.net/repo/a.mft"; let uri_b = "rsync://example.net/repo/b.roa"; let snapshot = snapshot_xml(sid, 1, &[(uri_a, b"a1"), (uri_b, b"b1")]); let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot)); let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash); let fetcher_1 = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher_1).expect("seed"); let index = CurrentRepoIndex::shared(); let no_fetcher = MapFetcher { map: HashMap::new(), }; let applied = sync_from_notification_with_timing_and_download_log( &store, notif_uri, Some(&index), ¬if, &no_fetcher, None, None, ) .expect("same serial no-op"); assert_eq!(applied, 0); let index = index.lock().expect("lock index"); assert_eq!(index.active_uri_count(), 2); assert!(index.get_by_uri(uri_a).is_some()); assert!(index.get_by_uri(uri_b).is_some()); } #[test] fn sync_from_notification_delta_hydrates_unchanged_current_repo_entries() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; let snapshot_uri_1 = "https://example.net/snapshot-1.xml"; let uri_a = "rsync://example.net/repo/a.mft"; let uri_b = "rsync://example.net/repo/b.roa"; let uri_c = "rsync://example.net/repo/c.crl"; let snapshot_1 = snapshot_xml(sid, 1, &[(uri_a, b"a1"), (uri_b, b"b1")]); let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1)); let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1); let fetcher_1 = MapFetcher { map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if_1, &fetcher_1).expect("seed"); let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2"); let delta_2 = delta_xml( sid, 2, &[&format!( r#"{publish_c_b64}"# )], ); let delta_2_hash_hex = hex::encode(sha2::Sha256::digest(&delta_2)); let notif_2 = notification_xml_with_deltas( sid, 2, "https://example.net/snapshot-2.xml", &"00".repeat(32), &[( "d2", 2, "https://example.net/delta-2.xml", &delta_2_hash_hex, )], ); let fetcher_2 = MapFetcher { map: HashMap::from([("https://example.net/delta-2.xml".to_string(), delta_2)]), }; let index = CurrentRepoIndex::shared(); let applied = sync_from_notification_with_timing_and_download_log( &store, notif_uri, Some(&index), ¬if_2, &fetcher_2, None, None, ) .expect("delta sync"); assert_eq!(applied, 1); let index = index.lock().expect("lock index"); assert_eq!(index.active_uri_count(), 3); assert!( index.get_by_uri(uri_a).is_some(), "unchanged object from the previous serial must be visible" ); assert!( index.get_by_uri(uri_b).is_some(), "unchanged object from the previous serial must be visible" ); assert!(index.get_by_uri(uri_c).is_some(), "delta publish visible"); } #[test] fn load_rrdp_local_state_uses_source_record_only() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let notif_uri = "https://example.net/notification.xml"; assert_eq!( load_rrdp_local_state(&store, notif_uri).expect("load empty"), None ); update_rrdp_source_record_on_success( &store, notif_uri, "source-session", 9, crate::storage::RrdpSourceSyncState::DeltaReady, Some("https://example.net/snapshot.xml"), Some(&hex::encode([0x11; 32])), ) .expect("write source record"); let got = load_rrdp_local_state(&store, notif_uri) .expect("load source preferred") .expect("source present"); assert_eq!( got, RrdpState { session_id: "source-session".to_string(), serial: 9, } ); } #[test] fn sync_from_notification_falls_back_to_snapshot_if_missing_required_deltas() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let notif_uri = "https://example.net/notification.xml"; // Seed state serial=1 with a only. let snapshot_uri_1 = "https://example.net/snapshot-1.xml"; let snapshot_1 = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]); let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1)); let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1); let fetcher_1 = MapFetcher { map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]), }; sync_from_notification_snapshot(&store, notif_uri, ¬if_1, &fetcher_1).expect("seed"); // Notification serial=3 only includes delta serial=3 (contiguous per RFC, but missing // serial=2 relative to our local state, so we must use snapshot). let snapshot_uri_3 = "https://example.net/snapshot-3.xml"; let snapshot_3 = snapshot_xml(sid, 3, &[("rsync://example.net/repo/z.roa", b"z3")]); let snapshot_hash_3 = hex::encode(sha2::Sha256::digest(&snapshot_3)); let delta_3_hash_hex = "11".repeat(32); let notif_3 = notification_xml_with_deltas( sid, 3, snapshot_uri_3, &snapshot_hash_3, &[( "d3", 3, "https://example.net/delta-3.xml", &delta_3_hash_hex, )], ); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri_3.to_string(), snapshot_3)]), }; let published = sync_from_notification(&store, notif_uri, ¬if_3, &fetcher).expect("sync"); assert_eq!(published, 1); assert!( store .load_current_object_bytes_by_uri("rsync://example.net/repo/z.roa") .expect("get current") .is_some() ); } #[test] fn sync_from_notification_snapshot_rejects_snapshot_hash_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let sid = "550e8400-e29b-41d4-a716-446655440000"; let serial = 1u64; let notif_uri = "https://example.net/notification.xml"; let snapshot_uri = "https://example.net/snapshot.xml"; let snapshot = snapshot_xml(sid, serial, &[("rsync://example.net/repo/a.mft", b"x")]); let notif = notification_xml(sid, serial, snapshot_uri, &"00".repeat(32)); let fetcher = MapFetcher { map: HashMap::from([(snapshot_uri.to_string(), snapshot)]), }; let err = sync_from_notification_snapshot(&store, notif_uri, ¬if, &fetcher).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch) )); } #[test] fn apply_snapshot_rejects_session_id_and_serial_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let notif_uri = "https://example.net/notification.xml"; let expected_sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let got_sid = "550e8400-e29b-41d4-a716-446655440001"; let snapshot = snapshot_xml(got_sid, 2, &[("rsync://example.net/repo/a.mft", b"x")]); let err = apply_snapshot(&store, notif_uri, None, &snapshot, expected_sid, 2).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::SnapshotSessionIdMismatch { .. }) )); let snapshot = snapshot_xml( expected_sid.to_string().as_str(), 3, &[("rsync://example.net/repo/a.mft", b"x")], ); let err = apply_snapshot(&store, notif_uri, None, &snapshot, expected_sid, 2).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::SnapshotSerialMismatch { .. }) )); } #[test] fn strip_all_ascii_whitespace_removes_newlines_and_spaces() { assert_eq!(strip_all_ascii_whitespace(" a \n b\tc "), "abc"); } #[test] fn apply_snapshot_reports_publish_errors() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let notif_uri = "https://example.net/notification.xml"; let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); // Missing publish/@uri let xml = format!( r#"AA=="# ) .into_bytes(); let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::PublishUriMissing) )); // Missing base64 content (no text nodes). let xml = format!( r#""# ) .into_bytes(); let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::PublishContentMissing) )); // Invalid base64 content. let xml = format!( r#"!!!"# ) .into_bytes(); let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err(); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::PublishBase64(_)) )); } #[test] fn apply_snapshot_handles_multiple_publish_batches() { let tmp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(tmp.path()).expect("open rocksdb"); let notif_uri = "https://example.net/notification.xml"; let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let total = RRDP_SNAPSHOT_APPLY_BATCH_SIZE + 7; let mut xml = format!(r#""#); for i in 0..total { let uri = format!("rsync://example.net/repo/{i:04}.roa"); let bytes = format!("payload-{i}").into_bytes(); let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); xml.push_str(&format!(r#"{b64}"#)); } xml.push_str(""); let published = apply_snapshot(&store, notif_uri, None, xml.as_bytes(), sid, 1).expect("apply snapshot"); assert_eq!(published, total); for idx in [0usize, RRDP_SNAPSHOT_APPLY_BATCH_SIZE - 1, total - 1] { let uri = format!("rsync://example.net/repo/{idx:04}.roa"); let got = store .load_current_object_bytes_by_uri(&uri) .expect("load object") .expect("object exists"); assert_eq!(got, format!("payload-{idx}").into_bytes()); } }