// RRDP test group: sync. #[test] fn snapshot_entity_reference_is_not_silently_dropped_from_base64() { for content in ["YQ==&", "YQ==", "YQ==&unknown;"] { let temp = tempfile::tempdir().unwrap(); let store = RocksStore::open(temp.path()).unwrap(); let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let xml = format!( "{content}" ); let error = super::snapshot_apply::apply_snapshot( &store, "https://example.net/notification.xml", None, xml.as_bytes(), sid, 1, ) .expect_err("an entity must not disappear and turn invalid base64 into valid data"); assert!(matches!( error, RrdpSyncError::Rrdp(RrdpError::PublishBase64(_)) )); } } #[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}"); } struct PanicOnRrdpFetch; impl Fetcher for PanicOnRrdpFetch { fn fetch(&self, uri: &str) -> Result, String> { panic!("cross-origin notification must be rejected before fetch: {uri}"); } } #[test] fn cross_origin_snapshot_reference_is_rejected_before_fetch_or_state_write() { let temp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(temp.path()).expect("open rocksdb"); let notification_uri = "https://origin.example.test/notification.xml"; let notification = notification_xml( "550e8400-e29b-41d4-a716-446655440000", 1, "https://foreign.example.test/snapshot.xml", &"00".repeat(32), ); let err = sync_from_notification_snapshot(&store, notification_uri, ¬ification, &PanicOnRrdpFetch) .expect_err("cross-origin snapshot must be rejected"); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::CrossOriginReference { resource: RrdpResourceKind::Snapshot, .. }) )); assert!( load_rrdp_local_state(&store, notification_uri) .expect("read state") .is_none(), "rejection must precede RRDP state writes" ); } #[test] fn cross_origin_delta_reference_is_rejected_even_when_delta_would_not_be_used() { let temp = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(temp.path()).expect("open rocksdb"); let notification_uri = "https://origin.example.test/notification.xml"; let notification = notification_xml_with_deltas( "550e8400-e29b-41d4-a716-446655440000", 1, "https://origin.example.test/snapshot.xml", &"00".repeat(32), &[( "delta-1", 1, "https://foreign.example.test/delta-1.xml", &"11".repeat(32), )], ); let err = sync_from_notification(&store, notification_uri, ¬ification, &PanicOnRrdpFetch) .expect_err("cross-origin delta must be rejected before state/fetch"); assert!(matches!( err, RrdpSyncError::Rrdp(RrdpError::CrossOriginReference { resource: RrdpResourceKind::Delta, .. }) )); assert!( load_rrdp_local_state(&store, notification_uri) .expect("read state") .is_none(), "all delta references are checked before state reads or writes" ); } #[test] fn rrdp_origin_treats_https_default_port_as_equivalent() { let notification = parse_notification(¬ification_xml( "550e8400-e29b-41d4-a716-446655440000", 1, "https://origin.example.test:443/snapshot.xml", &"00".repeat(32), )) .expect("notification"); validate_notification_references( "https://origin.example.test/notification.xml", ¬ification, ) .expect("explicit HTTPS default port must remain same-origin"); } #[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::repository::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::repository::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::repository::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.read().expect("read-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.read().expect("read-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"); }