1317 lines
45 KiB
Rust
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,
|
|
¬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"<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, ¬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#"<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, ¬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 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, ¬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#"<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, ¬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 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, ¬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 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),
|
|
¬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#"<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());
|
|
}
|
|
}
|