rpki/src/replay/delta_fetch_http.rs
yuyr 557a69cbd2 20260316迭代 增加delta replay以及multi-rir
replay 对比,五个RIR 输出vrp与routinator一致
2026-03-16 22:54:48 +08:00

369 lines
15 KiB
Rust

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::replay::delta_archive::{
ReplayDeltaArchiveError, ReplayDeltaArchiveIndex, ReplayDeltaRrdpKind,
};
use crate::sync::rrdp::{Fetcher, parse_notification};
#[derive(Debug, thiserror::Error)]
pub enum PayloadDeltaReplayHttpFetcherError {
#[error(transparent)]
DeltaIndex(#[from] ReplayDeltaArchiveError),
#[error("read delta replay RRDP file failed: {path}: {detail}")]
ReadFile { path: String, detail: String },
#[error("parse target notification failed for {notify_uri}: {detail}")]
ParseNotification { notify_uri: String, detail: String },
#[error(
"target notification session/serial mismatch for {notify_uri}: expected session={expected_session} serial={expected_serial}, got session={actual_session} serial={actual_serial}"
)]
NotificationTargetMismatch {
notify_uri: String,
expected_session: String,
expected_serial: u64,
actual_session: String,
actual_serial: u64,
},
#[error(
"delta serial list mismatch between target notification and transition for {notify_uri}"
)]
DeltaSerialMismatch { notify_uri: String },
#[error("duplicate delta replay HTTP URI mapping for {uri}: {first_path} vs {second_path}")]
DuplicateUriMapping {
uri: String,
first_path: String,
second_path: String,
},
#[error(
"delta replay notification URI is {kind} and should not be fetched as RRDP: {notify_uri}"
)]
NotificationKindNotFetchable { notify_uri: String, kind: String },
#[error("delta replay HTTP URI not found in archive: {0}")]
MissingUri(String),
}
#[derive(Clone, Debug)]
pub struct PayloadDeltaReplayHttpFetcher {
index: Arc<ReplayDeltaArchiveIndex>,
routes: BTreeMap<String, PathBuf>,
repo_kinds: BTreeMap<String, ReplayDeltaRrdpKind>,
}
impl PayloadDeltaReplayHttpFetcher {
pub fn new(
index: Arc<ReplayDeltaArchiveIndex>,
) -> Result<Self, PayloadDeltaReplayHttpFetcherError> {
let mut routes = BTreeMap::new();
let mut repo_kinds = BTreeMap::new();
for (notify_uri, repo) in &index.rrdp_repos {
repo_kinds.insert(notify_uri.clone(), repo.transition.kind);
if repo.transition.kind != ReplayDeltaRrdpKind::Delta {
continue;
}
let notification_path = repo
.target_notification_path
.as_ref()
.expect("delta repo target notification indexed");
insert_unique_route(&mut routes, notify_uri, notification_path)?;
let notification_xml = fs::read(notification_path).map_err(|e| {
PayloadDeltaReplayHttpFetcherError::ReadFile {
path: notification_path.display().to_string(),
detail: e.to_string(),
}
})?;
let notification = parse_notification(&notification_xml).map_err(|e| {
PayloadDeltaReplayHttpFetcherError::ParseNotification {
notify_uri: notify_uri.clone(),
detail: e.to_string(),
}
})?;
let expected_session = repo.transition.target.session.as_deref().unwrap_or("");
let expected_serial = repo.transition.target.serial.unwrap_or_default();
let actual_session = notification.session_id.to_string();
if actual_session != expected_session || notification.serial != expected_serial {
return Err(
PayloadDeltaReplayHttpFetcherError::NotificationTargetMismatch {
notify_uri: notify_uri.clone(),
expected_session: expected_session.to_string(),
expected_serial,
actual_session,
actual_serial: notification.serial,
},
);
}
let transition_serials = repo
.delta_paths
.iter()
.map(|(serial, _)| *serial)
.collect::<Vec<_>>();
let mut notification_delta_map = BTreeMap::new();
for dref in notification.deltas {
notification_delta_map.insert(dref.serial, dref.uri);
}
for serial in &transition_serials {
if !notification_delta_map.contains_key(serial) {
return Err(PayloadDeltaReplayHttpFetcherError::DeltaSerialMismatch {
notify_uri: notify_uri.clone(),
});
}
}
for (serial, path) in &repo.delta_paths {
let uri = notification_delta_map
.get(serial)
.expect("delta uri present for transition serial");
insert_unique_route(&mut routes, uri, path)?;
}
}
Ok(Self {
index,
routes,
repo_kinds,
})
}
pub fn from_index(
index: Arc<ReplayDeltaArchiveIndex>,
) -> Result<Self, PayloadDeltaReplayHttpFetcherError> {
Self::new(index)
}
pub fn archive_index(&self) -> &ReplayDeltaArchiveIndex {
self.index.as_ref()
}
}
impl Fetcher for PayloadDeltaReplayHttpFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
if let Some(path) = self.routes.get(uri) {
return fs::read(path).map_err(|e| {
PayloadDeltaReplayHttpFetcherError::ReadFile {
path: path.display().to_string(),
detail: e.to_string(),
}
.to_string()
});
}
if let Some(kind) = self.repo_kinds.get(uri) {
return Err(
PayloadDeltaReplayHttpFetcherError::NotificationKindNotFetchable {
notify_uri: uri.to_string(),
kind: kind.as_str().to_string(),
}
.to_string(),
);
}
Err(PayloadDeltaReplayHttpFetcherError::MissingUri(uri.to_string()).to_string())
}
}
fn insert_unique_route(
routes: &mut BTreeMap<String, PathBuf>,
uri: &str,
path: &Path,
) -> Result<(), PayloadDeltaReplayHttpFetcherError> {
if let Some(existing) = routes.get(uri) {
if existing != path {
return Err(PayloadDeltaReplayHttpFetcherError::DuplicateUriMapping {
uri: uri.to_string(),
first_path: existing.display().to_string(),
second_path: path.display().to_string(),
});
}
return Ok(());
}
routes.insert(uri.to_string(), path.to_path_buf());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::replay::archive::sha256_hex;
use crate::replay::delta_archive::ReplayDeltaArchiveIndex;
fn build_delta_http_fixture(
kind: ReplayDeltaRrdpKind,
) -> (tempfile::TempDir, PathBuf, PathBuf, String, String, String) {
let temp = tempfile::tempdir().expect("tempdir");
let archive_root = temp.path().join("payload-delta-archive");
let capture = "delta-http";
let capture_root = archive_root.join("v1").join("captures").join(capture);
std::fs::create_dir_all(&capture_root).expect("mkdir capture root");
std::fs::write(
capture_root.join("capture.json"),
format!(r#"{{"version":1,"captureId":"{capture}","createdAt":"2026-03-16T00:00:00Z","notes":""}}"#),
)
.expect("write capture meta");
std::fs::write(
capture_root.join("base.json"),
r#"{"version":1,"baseCapture":"base-cap","baseLocksSha256":"deadbeef","createdAt":"2026-03-16T00:00:00Z"}"#,
)
.expect("write base meta");
let notify_uri = "https://rrdp.example.test/notification.xml".to_string();
let snapshot_uri = "https://rrdp.example.test/snapshot.xml".to_string();
let delta1_uri = "https://rrdp.example.test/d1.xml".to_string();
let delta2_uri = "https://rrdp.example.test/d2.xml".to_string();
let session = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string();
let target_serial = 12u64;
let repo_hash = sha256_hex(notify_uri.as_bytes());
let session_dir = capture_root
.join("rrdp/repos")
.join(&repo_hash)
.join(&session);
let deltas_dir = session_dir.join("deltas");
std::fs::create_dir_all(&deltas_dir).expect("mkdir deltas dir");
std::fs::write(
session_dir.parent().unwrap().join("meta.json"),
format!(r#"{{"version":1,"rpkiNotify":"{notify_uri}","createdAt":"2026-03-16T00:00:00Z","lastSeenAt":"2026-03-16T00:00:01Z"}}"#),
)
.expect("write repo meta");
let notification_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="{target_serial}">
<snapshot uri="{snapshot_uri}" hash="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" />
<delta serial="11" uri="{delta1_uri}" hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" />
<delta serial="12" uri="{delta2_uri}" hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" />
</notification>"#
);
std::fs::write(
session_dir.join("notification-target-12.xml"),
notification_xml,
)
.expect("write target notification");
std::fs::write(
deltas_dir.join("delta-11-aaaa.xml"),
b"<delta serial='11'/>",
)
.expect("write delta1");
std::fs::write(
deltas_dir.join("delta-12-bbbb.xml"),
b"<delta serial='12'/>",
)
.expect("write delta2");
std::fs::write(
session_dir.parent().unwrap().join("transition.json"),
format!(
r#"{{"kind":"{}","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}}"#,
kind.as_str()
),
)
.expect("write transition");
let locks_path = temp.path().join("locks-delta.json");
std::fs::write(
&locks_path,
format!(
r#"{{"version":1,"capture":"{capture}","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{{"{notify_uri}":{{"kind":"{}","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}}}},"rsync":{{}}}}"#,
kind.as_str()
),
)
.expect("write locks");
(
temp,
archive_root,
locks_path,
notify_uri,
delta1_uri,
delta2_uri,
)
}
#[test]
fn delta_http_fetcher_rejects_session_reset_and_gap_notification_kind() {
for kind in [ReplayDeltaRrdpKind::SessionReset, ReplayDeltaRrdpKind::Gap] {
let (_temp, archive_root, locks_path, notify_uri, _delta1_uri, _delta2_uri) =
build_delta_http_fixture(kind);
let index = Arc::new(
ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
.expect("load delta index"),
);
let fetcher = PayloadDeltaReplayHttpFetcher::from_index(index).expect("build fetcher");
let err = fetcher.fetch(&notify_uri).unwrap_err();
assert!(err.contains(kind.as_str()), "{err}");
}
}
#[test]
fn delta_http_fetcher_reads_target_notification_and_delta_files() {
let (_temp, archive_root, locks_path, notify_uri, delta1_uri, delta2_uri) =
build_delta_http_fixture(ReplayDeltaRrdpKind::Delta);
let index = Arc::new(
ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"),
);
let fetcher = PayloadDeltaReplayHttpFetcher::from_index(index).expect("build fetcher");
let notification = fetcher.fetch(&notify_uri).expect("fetch notification");
assert!(
std::str::from_utf8(&notification)
.unwrap()
.contains("notification")
);
assert_eq!(
fetcher.fetch(&delta1_uri).expect("fetch delta1"),
b"<delta serial='11'/>".to_vec()
);
assert_eq!(
fetcher.fetch(&delta2_uri).expect("fetch delta2"),
b"<delta serial='12'/>".to_vec()
);
}
#[test]
fn delta_http_fetcher_rejects_non_delta_notification_kinds_and_missing_uri() {
let (_temp, archive_root, locks_path, notify_uri, _delta1_uri, _delta2_uri) =
build_delta_http_fixture(ReplayDeltaRrdpKind::Unchanged);
let index = Arc::new(
ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"),
);
let fetcher = PayloadDeltaReplayHttpFetcher::from_index(index).expect("build fetcher");
let err = fetcher.fetch(&notify_uri).unwrap_err();
assert!(err.contains("unchanged"), "{err}");
let err = fetcher
.fetch("https://missing.example/test.xml")
.unwrap_err();
assert!(err.contains("not found in archive"), "{err}");
}
#[test]
fn delta_http_fetcher_rejects_target_notification_mismatch() {
let (_temp, archive_root, locks_path, notify_uri, _delta1_uri, _delta2_uri) =
build_delta_http_fixture(ReplayDeltaRrdpKind::Delta);
let repo_hash = sha256_hex(notify_uri.as_bytes());
let notification = archive_root
.join("v1/captures/delta-http/rrdp/repos")
.join(repo_hash)
.join("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.join("notification-target-12.xml");
std::fs::write(
&notification,
r#"<?xml version="1.0" encoding="UTF-8"?>
<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" serial="12">
<snapshot uri="https://rrdp.example.test/snapshot.xml" hash="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" />
<delta serial="11" uri="https://rrdp.example.test/d1.xml" hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" />
<delta serial="12" uri="https://rrdp.example.test/d2.xml" hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" />
</notification>"#,
)
.expect("rewrite notification");
let index = Arc::new(
ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"),
);
let err = PayloadDeltaReplayHttpFetcher::from_index(index).unwrap_err();
assert!(
matches!(
err,
PayloadDeltaReplayHttpFetcherError::NotificationTargetMismatch { .. }
),
"{err}"
);
}
}