yuyr 591bed90a8
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled
ci / audit (push) Has been cancelled
Harden first-release validation and dependency hygiene
2026-09-10 09:53:45 +08:00

502 lines
17 KiB
Rust

// 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!(
"<snapshot xmlns=\"{RRDP_XMLNS}\" version=\"1\" session_id=\"{sid}\" serial=\"1\"><publish uri=\"rsync://example.net/repo/a.roa\">{content}</publish></snapshot>"
);
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, &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}");
}
struct PanicOnRrdpFetch;
impl Fetcher for PanicOnRrdpFetch {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, 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, &notification, &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, &notification, &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(&notification_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",
&notification,
)
.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, &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::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, &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.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, &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.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");
}