rpki/src/sync/rrdp/tests.rs

1317 lines
45 KiB
Rust

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<String, Vec<u8>>,
}
impl Fetcher for MapFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, 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<Vec<u8>, String> {
if uri == self.sleep_uri {
std::thread::sleep(self.sleep);
}
self.inner.fetch(uri)
}
}
struct WriterOnlyFetcher {
map: HashMap<String, Vec<u8>>,
}
impl Fetcher for WriterOnlyFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
Err(format!("unexpected buffered fetch: {uri}"))
}
fn fetch_to_writer(&self, uri: &str, out: &mut dyn std::io::Write) -> Result<u64, String> {
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<Vec<u8>, String> {
Err(format!("unexpected buffered fetch: {uri}"))
}
fn fetch_to_writer(&self, _uri: &str, out: &mut dyn std::io::Write) -> Result<u64, String> {
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<u8> {
format!(
r#"<notification xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/></notification>"#
)
.into_bytes()
}
fn notification_xml_with_deltas(
session_id: &str,
serial: u64,
snapshot_uri: &str,
snapshot_hash: &str,
deltas: &[(&str, u64, &str, &str)],
) -> Vec<u8> {
let mut out = format!(
r#"<notification xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/>"#
);
for (_name, delta_serial, uri, hash) in deltas {
out.push_str(&format!(
r#"<delta serial="{delta_serial}" uri="{uri}" hash="{hash}"/>"#
));
}
out.push_str("</notification>");
out.into_bytes()
}
fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
);
for (uri, bytes) in published {
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
out.push_str(&format!(r#"<publish uri="{uri}">{b64}</publish>"#));
}
out.push_str("</snapshot>");
out.into_bytes()
}
#[test]
fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
let snapshot_uri = "https://example.test/snapshot.xml";
let snapshot = b"<snapshot xmlns=\"http://www.ripe.net/rpki/rrdp\" version=\"1\"/>".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,
&notif,
&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"<notification/>".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<u8> {
let mut out = format!(
r#"<delta xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
);
for e in elements {
out.push_str(e);
}
out.push_str("</delta>");
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 uri="rsync://example.net/repo/a.mft">{publish_b64}</publish>"#),
&format!(r#"<withdraw uri="rsync://example.net/repo/b.cer" hash="{withdraw_hash}"/>"#),
],
);
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#"<withdraw uri="rsync://example.net/repo/b.cer" hash="{withdraw_hash}">AA==</withdraw>"#
)],
);
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, &notif, &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#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{withdraw_a_hash}"/>"#
),
&format!(
r#"<publish uri="rsync://example.net/repo/b.roa" hash="{old_b_hash}">{replace_b_b64}</publish>"#
),
&format!(r#"<publish uri="rsync://example.net/repo/c.crl">{publish_c_b64}</publish>"#),
],
);
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<_>>(),
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#"<publish uri="rsync://example.net/repo/a.mft">QQ==</publish>"#],
);
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#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{withdraw_hash}"/>"#
)],
);
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, &notif, &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 uri="rsync://example.net/repo/a.mft">{publish_b64}</publish>"#
)],
);
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, &notif, &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#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{wrong_hash}"/>"#
)],
);
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#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{old_hash}"/>"#
)],
);
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 uri="rsync://example.net/repo/x.cer">{publish_b64}</publish>"#
)],
);
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, &notif_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, &notif_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, &notif, &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, &notif_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, &notif_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, &notif_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 uri="rsync://example.net/repo/c.crl">{publish_c_b64}</publish>"#
)],
);
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#"<withdraw uri="rsync://example.net/repo/c.crl" hash="{c_hash_hex}"/>"#
)],
);
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, &notif_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, &notif, &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),
&notif,
&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, &notif_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 uri="{uri_c}">{publish_c_b64}</publish>"#
)],
);
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),
&notif_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, &notif_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, &notif_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, &notif, &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#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish>AA==</publish></snapshot>"#
)
.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#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer"></publish></snapshot>"#
)
.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#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer">!!!</publish></snapshot>"#
)
.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#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1">"#);
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#"<publish uri="{uri}">{b64}</publish>"#));
}
xml.push_str("</snapshot>");
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());
}
}