yuyr 15c8de82ba
Some checks failed
ci / rust (push) Has been cancelled
ci / docker-runtime (push) Has been cancelled
freeze initial panda-rpki staging
2026-09-01 12:13:53 +08:00

1333 lines
47 KiB
Rust

use super::*;
use crate::analysis::timing::{TimingHandle, TimingMeta};
use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::replay::archive::{ReplayArchiveIndex, sha256_hex};
use crate::replay::delta_archive::ReplayDeltaArchiveIndex;
use crate::replay::delta_fetch_http::PayloadDeltaReplayHttpFetcher;
use crate::replay::delta_fetch_rsync::PayloadDeltaReplayRsyncFetcher;
use crate::replay::fetch_http::PayloadReplayHttpFetcher;
use crate::replay::fetch_rsync::PayloadReplayRsyncFetcher;
use crate::storage::RepositoryViewState;
use crate::sync::rrdp::Fetcher as HttpFetcher;
use crate::sync::rrdp::RrdpState;
use crate::sync::store_projection::{build_repository_view_present_entry, compute_sha256_hex};
use base64::Engine;
use sha2::Digest;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct DummyHttpFetcher;
impl HttpFetcher for DummyHttpFetcher {
fn fetch(&self, _url: &str) -> Result<Vec<u8>, String> {
panic!("http fetcher must not be used in rsync-only mode")
}
}
struct PanicRsyncFetcher;
impl RsyncFetcher for PanicRsyncFetcher {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
panic!("rsync must not be used in this test")
}
}
struct MapFetcher {
map: HashMap<String, Vec<u8>>,
}
impl HttpFetcher for MapFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.map
.get(uri)
.cloned()
.ok_or_else(|| format!("not found: {uri}"))
}
}
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())
);
}
#[test]
fn rsync_sync_uses_fetcher_dedup_scope_for_repository_view_projection() {
struct ScopeFetcher;
impl RsyncFetcher for ScopeFetcher {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
Ok(vec![(
"rsync://example.net/repo/child/a.mft".to_string(),
b"manifest".to_vec(),
)])
}
fn dedup_key(&self, _rsync_base_uri: &str) -> String {
"rsync://example.net/repo/".to_string()
}
}
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let seeded = build_repository_view_present_entry(
"rsync://example.net/repo/",
"rsync://example.net/repo/sibling/old.roa",
&compute_sha256_hex(b"old"),
);
store
.put_projection_batch(&[seeded], &[], &[])
.expect("seed repository view");
let fetcher = ScopeFetcher;
let written = rsync_sync_into_current_store(
&store,
"rsync://example.net/repo/child/",
None,
&fetcher,
None,
None,
)
.expect("sync ok");
assert_eq!(written, 1);
let entries = store
.list_repository_view_entries_with_prefix("rsync://example.net/repo/")
.expect("list repository view");
let sibling = entries
.iter()
.find(|entry| entry.rsync_uri == "rsync://example.net/repo/sibling/old.roa")
.expect("sibling entry exists");
assert_eq!(sibling.state, RepositoryViewState::Withdrawn);
let child = entries
.iter()
.find(|entry| entry.rsync_uri == "rsync://example.net/repo/child/a.mft")
.expect("child entry exists");
assert_eq!(child.state, RepositoryViewState::Present);
}
fn notification_xml(
session_id: &str,
serial: u64,
snapshot_uri: &str,
snapshot_hash: &str,
) -> Vec<u8> {
format!(
r#"<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/></notification>"#
)
.into_bytes()
}
fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = format!(
r#"<snapshot xmlns="http://www.ripe.net/rpki/rrdp" 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()
}
fn build_replay_archive_fixture() -> (
tempfile::TempDir,
std::path::PathBuf,
std::path::PathBuf,
String,
String,
String,
String,
) {
let temp = tempfile::tempdir().expect("tempdir");
let archive_root = temp.path().join("payload-archive");
let capture = "repo-replay";
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-13T00:00:00Z","notes":""}}"#
),
)
.expect("write capture json");
let notify_uri = "https://rrdp.example.test/notification.xml".to_string();
let snapshot_uri = "https://rrdp.example.test/snapshot.xml".to_string();
let session = "00000000-0000-0000-0000-000000000001".to_string();
let serial = 7u64;
let published_uri = "rsync://example.test/repo/a.mft".to_string();
let published_bytes = b"mft";
let snapshot = snapshot_xml(&session, serial, &[(&published_uri, published_bytes)]);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notification = notification_xml(&session, serial, &snapshot_uri, &snapshot_hash);
let repo_hash = sha256_hex(notify_uri.as_bytes());
let session_dir = capture_root
.join("rrdp/repos")
.join(&repo_hash)
.join(&session);
std::fs::create_dir_all(&session_dir).expect("mkdir session dir");
std::fs::write(
session_dir.parent().unwrap().join("meta.json"),
format!(
r#"{{"version":1,"rpkiNotify":"{notify_uri}","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}}"#
),
)
.expect("write repo meta");
std::fs::write(session_dir.join("notification-7.xml"), notification)
.expect("write notification");
std::fs::write(
session_dir.join(format!("snapshot-7-{snapshot_hash}.xml")),
&snapshot,
)
.expect("write snapshot");
let rsync_base_uri = "rsync://rsync.example.test/repo/".to_string();
let rsync_locked_notify = "https://rrdp-fallback.example.test/notification.xml".to_string();
let mod_hash = sha256_hex(rsync_base_uri.as_bytes());
let module_bucket_dir = capture_root.join("rsync/modules").join(&mod_hash);
let module_root = module_bucket_dir
.join("tree")
.join("rsync.example.test")
.join("repo");
std::fs::create_dir_all(module_root.join("sub")).expect("mkdir module tree");
std::fs::write(
module_bucket_dir.join("meta.json"),
format!(
r#"{{"version":1,"module":"{rsync_base_uri}","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}}"#
),
)
.expect("write rsync meta");
std::fs::write(module_root.join("sub").join("fallback.cer"), b"cer")
.expect("write rsync object");
let locks_path = temp.path().join("locks.json");
std::fs::write(
&locks_path,
format!(
r#"{{
"version":1,
"capture":"{capture}",
"rrdp":{{
"{notify_uri}":{{"transport":"rrdp","session":"{session}","serial":{serial}}},
"{rsync_locked_notify}":{{"transport":"rsync","session":null,"serial":null}}
}},
"rsync":{{
"{rsync_base_uri}":{{"transport":"rsync"}}
}}
}}"#
),
)
.expect("write locks");
(
temp,
archive_root,
locks_path,
notify_uri,
rsync_locked_notify,
rsync_base_uri,
published_uri,
)
}
fn build_delta_replay_fixture() -> (
tempfile::TempDir,
std::path::PathBuf,
std::path::PathBuf,
std::path::PathBuf,
std::path::PathBuf,
String,
String,
String,
) {
let temp = tempfile::tempdir().expect("tempdir");
let base_archive = temp.path().join("payload-archive");
let base_capture_root = base_archive.join("v1/captures/base-cap");
std::fs::create_dir_all(&base_capture_root).expect("mkdir base capture");
std::fs::write(
base_capture_root.join("capture.json"),
r#"{"version":1,"captureId":"base-cap","createdAt":"2026-03-16T00:00:00Z","notes":""}"#,
)
.expect("write base capture meta");
let notify_uri = "https://rrdp.example.test/notification.xml".to_string();
let snapshot_uri = "https://rrdp.example.test/snapshot.xml".to_string();
let session = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string();
let base_serial = 10u64;
let delta1_uri = "https://rrdp.example.test/d1.xml".to_string();
let delta2_uri = "https://rrdp.example.test/d2.xml".to_string();
let repo_hash = sha256_hex(notify_uri.as_bytes());
let base_session_dir = base_capture_root
.join("rrdp/repos")
.join(&repo_hash)
.join(&session);
std::fs::create_dir_all(&base_session_dir).expect("mkdir base session dir");
std::fs::write(
base_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 base rrdp meta");
let base_snapshot = snapshot_xml(
&session,
base_serial,
&[("rsync://example.test/repo/a.mft", b"base")],
);
let base_snapshot_hash = hex::encode(sha2::Sha256::digest(&base_snapshot));
let base_notification =
notification_xml(&session, base_serial, &snapshot_uri, &base_snapshot_hash);
std::fs::write(
base_session_dir.join("notification-10.xml"),
base_notification,
)
.expect("write base notif");
std::fs::write(
base_session_dir.join(format!("snapshot-10-{base_snapshot_hash}.xml")),
base_snapshot,
)
.expect("write base snapshot");
let module_uri = "rsync://rsync.example.test/repo/".to_string();
let module_hash = sha256_hex(module_uri.as_bytes());
let base_module_bucket = base_capture_root.join("rsync/modules").join(&module_hash);
let base_module_tree = base_module_bucket.join("tree/rsync.example.test/repo");
std::fs::create_dir_all(base_module_tree.join("sub")).expect("mkdir base rsync tree");
std::fs::write(
base_module_bucket.join("meta.json"),
format!(r#"{{"version":1,"module":"{module_uri}","createdAt":"2026-03-16T00:00:00Z","lastSeenAt":"2026-03-16T00:00:01Z"}}"#),
)
.expect("write base module meta");
std::fs::write(base_module_tree.join("a.mft"), b"base").expect("write base a.mft");
std::fs::write(base_module_tree.join("sub").join("x.cer"), b"base-cer")
.expect("write base x.cer");
let base_locks = temp.path().join("base-locks.json");
let fallback_notify = "https://rrdp-fallback.example.test/notification.xml".to_string();
let base_locks_body = format!(
r#"{{"version":1,"capture":"base-cap","rrdp":{{"{notify_uri}":{{"transport":"rrdp","session":"{session}","serial":10}},"{fallback_notify}":{{"transport":"rsync","session":null,"serial":null}}}},"rsync":{{"{module_uri}":{{"transport":"rsync"}}}}}}"#
);
std::fs::write(&base_locks, &base_locks_body).expect("write base locks");
let base_locks_sha = sha256_hex(base_locks_body.as_bytes());
let delta_archive = temp.path().join("payload-delta-archive");
let delta_capture_root = delta_archive.join("v1/captures/delta-cap");
std::fs::create_dir_all(&delta_capture_root).expect("mkdir delta capture");
std::fs::write(
delta_capture_root.join("capture.json"),
r#"{"version":1,"captureId":"delta-cap","createdAt":"2026-03-16T00:00:00Z","notes":""}"#,
)
.expect("write delta capture meta");
std::fs::write(
delta_capture_root.join("base.json"),
format!(r#"{{"version":1,"baseCapture":"base-cap","baseLocksSha256":"{base_locks_sha}","createdAt":"2026-03-16T00:00:00Z"}}"#),
)
.expect("write delta base meta");
let delta_session_dir = delta_capture_root
.join("rrdp/repos")
.join(&repo_hash)
.join(&session);
let delta_deltas_dir = delta_session_dir.join("deltas");
std::fs::create_dir_all(&delta_deltas_dir).expect("mkdir delta deltas");
std::fs::write(
delta_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 delta meta");
std::fs::write(
delta_session_dir.parent().unwrap().join("transition.json"),
format!(r#"{{"kind":"delta","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}}"#),
)
.expect("write delta transition");
let delta1 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="11"><publish uri="rsync://example.test/repo/a.mft">{}</publish></delta>"#,
base64::engine::general_purpose::STANDARD.encode(b"delta-a")
);
let delta2 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="12"><publish uri="rsync://example.test/repo/sub/b.roa">{}</publish></delta>"#,
base64::engine::general_purpose::STANDARD.encode(b"delta-b")
);
let delta1_hash = hex::encode(sha2::Sha256::digest(delta1.as_bytes()));
let delta2_hash = hex::encode(sha2::Sha256::digest(delta2.as_bytes()));
let target_notification = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="12">
<snapshot uri="{snapshot_uri}" hash="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" />
<delta serial="11" uri="{delta1_uri}" hash="{delta1_hash}" />
<delta serial="12" uri="{delta2_uri}" hash="{delta2_hash}" />
</notification>"#
);
std::fs::write(
delta_session_dir.join("notification-target-12.xml"),
target_notification,
)
.expect("write target notification");
std::fs::write(delta_deltas_dir.join("delta-11-aaaa.xml"), delta1).expect("write delta11");
std::fs::write(delta_deltas_dir.join("delta-12-bbbb.xml"), delta2).expect("write delta12");
let delta_module_bucket = delta_capture_root.join("rsync/modules").join(&module_hash);
let delta_module_tree = delta_module_bucket.join("tree/rsync.example.test/repo");
std::fs::create_dir_all(delta_module_tree.join("sub")).expect("mkdir delta rsync tree");
std::fs::write(
delta_module_bucket.join("meta.json"),
format!(r#"{{"version":1,"module":"{module_uri}","createdAt":"2026-03-16T00:00:00Z","lastSeenAt":"2026-03-16T00:00:01Z"}}"#),
)
.expect("write delta rsync meta");
std::fs::write(
delta_module_bucket.join("files.json"),
format!(r#"{{"version":1,"module":"{module_uri}","fileCount":1,"files":["{module_uri}sub/x.cer"]}}"#),
)
.expect("write delta files");
std::fs::write(delta_module_tree.join("sub").join("x.cer"), b"overlay-cer")
.expect("write overlay file");
let fallback_hash = sha256_hex(fallback_notify.as_bytes());
let fallback_repo_dir = delta_capture_root.join("rrdp/repos").join(&fallback_hash);
std::fs::create_dir_all(&fallback_repo_dir).expect("mkdir fallback repo dir");
std::fs::write(
fallback_repo_dir.join("meta.json"),
format!(r#"{{"version":1,"rpkiNotify":"{fallback_notify}","createdAt":"2026-03-16T00:00:00Z","lastSeenAt":"2026-03-16T00:00:01Z"}}"#),
)
.expect("write fallback meta");
std::fs::write(
fallback_repo_dir.join("transition.json"),
r#"{"kind":"fallback-rsync","base":{"transport":"rsync","session":null,"serial":null},"target":{"transport":"rsync","session":null,"serial":null},"delta_count":0,"deltas":[]}"#,
)
.expect("write fallback transition");
let delta_locks = temp.path().join("locks-delta.json");
std::fs::write(
&delta_locks,
format!(r#"{{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"{base_locks_sha}","rrdp":{{"{notify_uri}":{{"kind":"delta","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}},"{fallback_notify}":{{"kind":"fallback-rsync","base":{{"transport":"rsync","session":null,"serial":null}},"target":{{"transport":"rsync","session":null,"serial":null}},"delta_count":0,"deltas":[]}}}},"rsync":{{"{module_uri}":{{"file_count":1,"overlay_only":false}}}}}}"#),
)
.expect("write delta locks");
(
temp,
base_archive,
base_locks,
delta_archive,
delta_locks,
notify_uri,
fallback_notify,
module_uri,
)
}
fn timing_to_json(temp_dir: &std::path::Path, timing: &TimingHandle) -> serde_json::Value {
let timing_path = temp_dir.join("timing_retry.json");
timing.write_json(&timing_path, 50).expect("write json");
serde_json::from_slice(&std::fs::read(&timing_path).expect("read json")).expect("parse json")
}
#[test]
fn rsync_sync_writes_current_store_and_records_counts() {
let temp = tempfile::tempdir().expect("tempdir");
let repo_dir = temp.path().join("repo");
std::fs::create_dir_all(repo_dir.join("sub")).expect("mkdir");
std::fs::write(repo_dir.join("a.mft"), b"mft").expect("write");
std::fs::write(repo_dir.join("sub").join("b.roa"), b"roa").expect("write");
std::fs::write(repo_dir.join("sub").join("c.cer"), b"cer").expect("write");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
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()),
});
let policy = Policy {
sync_preference: SyncPreference::RsyncOnly,
..Policy::default()
};
let http = DummyHttpFetcher;
let rsync = LocalDirRsyncFetcher::new(&repo_dir);
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
None,
"rsync://example.test/repo/",
&http,
&rsync,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert_eq!(out.objects_written, 3);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, AuditDownloadKind::Rsync);
assert!(events[0].success);
assert_eq!(events[0].bytes, Some(9));
let objects = events[0].objects.as_ref().expect("objects stat");
assert_eq!(objects.objects_count, 3);
assert_eq!(objects.objects_bytes_total, 9);
assert_current_object(&store, "rsync://example.test/repo/a.mft", b"mft");
assert_current_object(&store, "rsync://example.test/repo/sub/b.roa", b"roa");
assert_current_object(&store, "rsync://example.test/repo/sub/c.cer", b"cer");
let view = store
.get_repository_view_entry("rsync://example.test/repo/a.mft")
.expect("get repository view")
.expect("repository view entry present");
assert_eq!(
view.current_hash.as_deref(),
Some(hex::encode(sha2::Sha256::digest(b"mft")).as_str())
);
assert_eq!(
view.repository_source.as_deref(),
Some("rsync://example.test/repo/")
);
let current_bytes = store
.load_current_object_bytes_by_uri("rsync://example.test/repo/sub/b.roa")
.expect("load current bytes")
.expect("current object bytes exist");
assert_eq!(current_bytes, b"roa".to_vec());
assert!(
store
.get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"roa")).as_str())
.expect("get raw_by_hash")
.is_none()
);
let timing_path = temp.path().join("timing.json");
timing.write_json(&timing_path, 5).expect("write json");
let v: serde_json::Value =
serde_json::from_slice(&std::fs::read(&timing_path).expect("read json"))
.expect("parse json");
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rsync_objects_fetched_total")
.and_then(|v| v.as_u64()),
Some(3)
);
assert_eq!(
counts
.get("rsync_objects_bytes_total")
.and_then(|v| v.as_u64()),
Some(3 * 3)
);
}
#[test]
fn rsync_second_sync_marks_missing_repository_view_entries_withdrawn() {
let temp = tempfile::tempdir().expect("tempdir");
let repo_dir = temp.path().join("repo");
std::fs::create_dir_all(repo_dir.join("sub")).expect("mkdir");
std::fs::write(repo_dir.join("a.mft"), b"mft-v1").expect("write a");
std::fs::write(repo_dir.join("sub").join("b.roa"), b"roa-v1").expect("write b");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let policy = Policy {
sync_preference: SyncPreference::RsyncOnly,
..Policy::default()
};
let http = DummyHttpFetcher;
let rsync = LocalDirRsyncFetcher::new(&repo_dir);
sync_publication_point(
&store,
&policy,
None,
"rsync://example.test/repo/",
&http,
&rsync,
None,
None,
)
.expect("first sync ok");
std::fs::remove_file(repo_dir.join("sub").join("b.roa")).expect("remove b");
std::fs::write(repo_dir.join("c.crl"), b"crl-v2").expect("write c");
sync_publication_point(
&store,
&policy,
None,
"rsync://example.test/repo/",
&http,
&rsync,
None,
None,
)
.expect("second sync ok");
let withdrawn = store
.get_repository_view_entry("rsync://example.test/repo/sub/b.roa")
.expect("get withdrawn repo view")
.expect("withdrawn entry exists");
assert_eq!(
withdrawn.state,
crate::storage::RepositoryViewState::Withdrawn
);
assert_eq!(
withdrawn.repository_source.as_deref(),
Some("rsync://example.test/repo/")
);
let added = store
.get_repository_view_entry("rsync://example.test/repo/c.crl")
.expect("get added repo view")
.expect("added entry exists");
assert_eq!(added.state, crate::storage::RepositoryViewState::Present);
}
#[test]
fn rrdp_fetch_error_falls_back_to_rsync_without_retry() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
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()),
});
let notification_uri = "https://example.test/notification.xml";
let published_uri = "rsync://example.test/repo/a.mft";
let published_bytes = b"x";
struct AlwaysFailHttp {
notification_calls: AtomicUsize,
}
impl HttpFetcher for AlwaysFailHttp {
fn fetch(&self, _uri: &str) -> Result<Vec<u8>, String> {
self.notification_calls.fetch_add(1, Ordering::SeqCst);
Err("http request failed: simulated transient".to_string())
}
}
struct SingleObjectRsync {
uri: String,
bytes: Vec<u8>,
}
impl RsyncFetcher for SingleObjectRsync {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
Ok(vec![(self.uri.clone(), self.bytes.clone())])
}
}
let http = AlwaysFailHttp {
notification_calls: AtomicUsize::new(0),
};
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&SingleObjectRsync {
uri: published_uri.to_string(),
bytes: published_bytes.to_vec(),
},
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert_current_object(&store, published_uri, published_bytes);
assert_eq!(http.notification_calls.load(Ordering::SeqCst), 1);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 2, "expected 1x notification + 1x rsync");
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification)
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification && !e.success)
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::Rsync)
.count(),
1
);
let v = timing_to_json(temp.path(), &timing);
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rrdp_retry_attempt_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rrdp_failed_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rsync_fallback_ok_total")
.and_then(|v| v.as_u64()),
Some(1)
);
}
#[test]
fn rrdp_protocol_error_does_not_retry_and_falls_back_to_rsync() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
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()),
});
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 snapshot = snapshot_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
&[(published_uri, published_bytes)],
);
// Intentionally wrong hash to trigger protocol error (SnapshotHashMismatch).
let wrong_hash = "00".repeat(32);
let notif = notification_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
snapshot_uri,
&wrong_hash,
);
let mut map = HashMap::new();
map.insert(notification_uri.to_string(), notif);
map.insert(snapshot_uri.to_string(), snapshot);
let http = MapFetcher { map };
struct EmptyRsyncFetcher;
impl RsyncFetcher for EmptyRsyncFetcher {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
Ok(Vec::new())
}
}
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&EmptyRsyncFetcher,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert!(
out.warnings
.iter()
.any(|w| w.message.contains("RRDP failed; falling back to rsync")),
"expected RRDP fallback warning"
);
let events = download_log.snapshot_events();
assert_eq!(
events.len(),
3,
"expected notification + snapshot + rsync fallback"
);
assert_eq!(events[0].kind, AuditDownloadKind::RrdpNotification);
assert!(events[0].success);
assert_eq!(events[1].kind, AuditDownloadKind::RrdpSnapshot);
assert!(!events[1].success);
assert_eq!(events[2].kind, AuditDownloadKind::Rsync);
assert!(events[2].success);
let v = timing_to_json(temp.path(), &timing);
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rrdp_retry_attempt_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("rrdp_failed_protocol_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rrdp_failed_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rsync_fallback_ok_total")
.and_then(|v| v.as_u64()),
Some(1)
);
}
#[test]
fn rrdp_delta_fetches_are_logged_even_if_snapshot_fallback_is_used() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
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()),
});
let notification_uri = "https://example.test/notification.xml";
let snapshot_uri = "https://example.test/snapshot.xml";
let delta_2_uri = "https://example.test/delta_2.xml";
let delta_3_uri = "https://example.test/delta_3.xml";
let published_uri = "rsync://example.test/repo/a.mft";
let published_bytes = b"x";
let sid = "9df4b597-af9e-4dca-bdda-719cce2c4e28";
// Seed old RRDP state so sync_from_notification tries deltas (RFC 8182 §3.4.1).
let state = RrdpState {
session_id: sid.to_string(),
serial: 1,
};
persist_rrdp_local_state(
&store,
notification_uri,
&state,
RrdpSourceSyncState::DeltaReady,
Some(snapshot_uri),
None,
)
.expect("seed state");
let delta_2 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="2"></delta>"#
)
.into_bytes();
let delta_3 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="3"></delta>"#
)
.into_bytes();
let delta_2_hash = hex::encode(sha2::Sha256::digest(&delta_2));
let delta_3_hash = hex::encode(sha2::Sha256::digest(&delta_3));
let snapshot = snapshot_xml(sid, 3, &[(published_uri, published_bytes)]);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notif = format!(
r#"<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="3"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/><delta serial="2" uri="{delta_2_uri}" hash="{delta_2_hash}"/><delta serial="3" uri="{delta_3_uri}" hash="{delta_3_hash}"/></notification>"#
)
.into_bytes();
let mut map = HashMap::new();
map.insert(notification_uri.to_string(), notif);
map.insert(snapshot_uri.to_string(), snapshot);
map.insert(delta_2_uri.to_string(), delta_2);
map.insert(delta_3_uri.to_string(), delta_3);
let http = MapFetcher { map };
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&PanicRsyncFetcher,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(out.objects_written, 1);
assert_current_object(&store, published_uri, published_bytes);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 4);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification)
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpDelta)
.count(),
2
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpSnapshot)
.count(),
1
);
assert!(events.iter().all(|e| e.success));
}
#[test]
fn replay_sync_uses_rrdp_when_locked_to_rrdp() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_archive_temp,
archive_root,
locks_path,
notify_uri,
_rsync_locked_notify,
_rsync_base_uri,
published_uri,
) = build_replay_archive_fixture();
let replay_index =
ReplayArchiveIndex::load(&archive_root, &locks_path).expect("load replay index");
let http = PayloadReplayHttpFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay http fetcher");
let rsync = PayloadReplayRsyncFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay rsync fetcher");
let out = sync_publication_point_replay(
&store,
&replay_index,
Some(&notify_uri),
"rsync://example.test/repo/",
&http,
&rsync,
None,
None,
)
.expect("replay sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(out.objects_written, 1);
assert_current_object(&store, &published_uri, b"mft");
}
#[test]
fn replay_sync_uses_rsync_when_notification_is_locked_to_rsync() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_archive_temp,
archive_root,
locks_path,
_notify_uri,
rsync_locked_notify,
rsync_base_uri,
_published_uri,
) = build_replay_archive_fixture();
let replay_index =
ReplayArchiveIndex::load(&archive_root, &locks_path).expect("load replay index");
let http = PayloadReplayHttpFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay http fetcher");
let rsync = PayloadReplayRsyncFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay rsync fetcher");
let out = sync_publication_point_replay(
&store,
&replay_index,
Some(&rsync_locked_notify),
&rsync_base_uri,
&http,
&rsync,
None,
None,
)
.expect("replay rsync sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert_eq!(out.objects_written, 1);
assert_eq!(out.warnings.len(), 0);
assert_current_object(
&store,
"rsync://rsync.example.test/repo/sub/fallback.cer",
b"cer",
);
}
#[test]
fn replay_sync_errors_when_lock_is_missing() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_archive_temp,
archive_root,
locks_path,
_notify_uri,
_rsync_locked_notify,
_rsync_base_uri,
_published_uri,
) = build_replay_archive_fixture();
let replay_index =
ReplayArchiveIndex::load(&archive_root, &locks_path).expect("load replay index");
let http = PayloadReplayHttpFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay http fetcher");
let rsync = PayloadReplayRsyncFetcher::from_paths(&archive_root, &locks_path)
.expect("build replay rsync fetcher");
let err = sync_publication_point_replay(
&store,
&replay_index,
Some("https://missing.example/notification.xml"),
"rsync://missing.example/repo/",
&http,
&rsync,
None,
None,
)
.unwrap_err();
assert!(matches!(err, RepoSyncError::Replay(_)), "{err}");
}
#[test]
fn delta_replay_sync_applies_rrdp_deltas_when_base_state_matches() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_fixture,
base_archive,
base_locks,
delta_archive,
delta_locks,
notify_uri,
_fallback_notify,
module_uri,
) = build_delta_replay_fixture();
let base_index =
Arc::new(ReplayArchiveIndex::load(&base_archive, &base_locks).expect("load base index"));
let delta_index = Arc::new(
ReplayDeltaArchiveIndex::load(&delta_archive, &delta_locks).expect("load delta index"),
);
let http = PayloadDeltaReplayHttpFetcher::from_index(delta_index.clone())
.expect("build delta http fetcher");
let rsync = PayloadDeltaReplayRsyncFetcher::new(base_index, delta_index.clone());
let state = RrdpState {
session_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(),
serial: 10,
};
persist_rrdp_local_state(
&store,
&notify_uri,
&state,
RrdpSourceSyncState::DeltaReady,
None,
None,
)
.expect("seed base state");
let out = sync_publication_point_replay_delta(
&store,
&delta_index,
Some(&notify_uri),
&module_uri,
&http,
&rsync,
None,
None,
)
.expect("delta sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(out.objects_written, 2);
assert_current_object(&store, "rsync://example.test/repo/a.mft", b"delta-a");
assert_current_object(&store, "rsync://example.test/repo/sub/b.roa", b"delta-b");
let new_state = load_rrdp_local_state(&store, &notify_uri)
.expect("load current state")
.expect("rrdp state present");
assert_eq!(new_state.serial, 12);
}
#[test]
fn delta_replay_sync_rejects_base_state_mismatch() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_fixture,
base_archive,
base_locks,
delta_archive,
delta_locks,
notify_uri,
_fallback_notify,
module_uri,
) = build_delta_replay_fixture();
let base_index =
Arc::new(ReplayArchiveIndex::load(&base_archive, &base_locks).expect("load base index"));
let delta_index = Arc::new(
ReplayDeltaArchiveIndex::load(&delta_archive, &delta_locks).expect("load delta index"),
);
let http = PayloadDeltaReplayHttpFetcher::from_index(delta_index.clone())
.expect("build delta http fetcher");
let rsync = PayloadDeltaReplayRsyncFetcher::new(base_index, delta_index.clone());
let err = sync_publication_point_replay_delta(
&store,
&delta_index,
Some(&notify_uri),
&module_uri,
&http,
&rsync,
None,
None,
)
.unwrap_err();
assert!(matches!(err, RepoSyncError::Replay(_)), "{err}");
}
#[test]
fn delta_replay_sync_noops_unchanged_rrdp_repo() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_fixture,
base_archive,
base_locks,
delta_archive,
delta_locks,
notify_uri,
_fallback_notify,
module_uri,
) = build_delta_replay_fixture();
let state = RrdpState {
session_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(),
serial: 10,
};
persist_rrdp_local_state(
&store,
&notify_uri,
&state,
RrdpSourceSyncState::DeltaReady,
None,
None,
)
.expect("seed base state");
let base_locks_body = std::fs::read_to_string(&base_locks).expect("read base locks");
let base_locks_sha = sha256_hex(base_locks_body.as_bytes());
std::fs::write(
&delta_locks,
format!(r#"{{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"{base_locks_sha}","rrdp":{{"{notify_uri}":{{"kind":"unchanged","base":{{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":10}},"target":{{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":10}},"delta_count":0,"deltas":[]}},"https://rrdp-fallback.example.test/notification.xml":{{"kind":"fallback-rsync","base":{{"transport":"rsync","session":null,"serial":null}},"target":{{"transport":"rsync","session":null,"serial":null}},"delta_count":0,"deltas":[]}}}},"rsync":{{"rsync://rsync.example.test/repo/":{{"file_count":1,"overlay_only":true}}}}}}"#),
)
.expect("rewrite delta locks");
let repo_hash = sha256_hex(notify_uri.as_bytes());
let repo_dir = delta_archive
.join("v1/captures/delta-cap/rrdp/repos")
.join(&repo_hash);
std::fs::write(
repo_dir.join("transition.json"),
r#"{"kind":"unchanged","base":{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":10},"target":{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":10},"delta_count":0,"deltas":[]}"#,
).expect("rewrite transition");
let delta_index =
ReplayDeltaArchiveIndex::load(&delta_archive, &delta_locks).expect("load delta index");
let http = PayloadDeltaReplayHttpFetcher::from_index(Arc::new(delta_index.clone()))
.expect("build delta http fetcher");
let base_index =
Arc::new(ReplayArchiveIndex::load(&base_archive, &base_locks).expect("load base index"));
let rsync = PayloadDeltaReplayRsyncFetcher::new(base_index, Arc::new(delta_index.clone()));
let out = sync_publication_point_replay_delta(
&store,
&delta_index,
Some(&notify_uri),
&module_uri,
&http,
&rsync,
None,
None,
)
.expect("unchanged delta sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(out.objects_written, 0);
}
#[test]
fn delta_replay_sync_uses_rsync_overlay_for_fallback_rsync_kind() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_fixture,
base_archive,
base_locks,
delta_archive,
delta_locks,
_notify_uri,
fallback_notify,
module_uri,
) = build_delta_replay_fixture();
let base_index =
Arc::new(ReplayArchiveIndex::load(&base_archive, &base_locks).expect("load base index"));
let delta_index = Arc::new(
ReplayDeltaArchiveIndex::load(&delta_archive, &delta_locks).expect("load delta index"),
);
let http = PayloadDeltaReplayHttpFetcher::from_index(delta_index.clone())
.expect("build delta http fetcher");
let rsync = PayloadDeltaReplayRsyncFetcher::new(base_index, delta_index.clone());
let out = sync_publication_point_replay_delta(
&store,
&delta_index,
Some(&fallback_notify),
&module_uri,
&http,
&rsync,
None,
None,
)
.expect("fallback-rsync delta sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert_eq!(out.objects_written, 2);
assert_current_object(&store, "rsync://rsync.example.test/repo/a.mft", b"base");
assert_current_object(
&store,
"rsync://rsync.example.test/repo/sub/x.cer",
b"overlay-cer",
);
}
#[test]
fn delta_replay_sync_rejects_session_reset_and_gap() {
for kind in ["session-reset", "gap"] {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let (
_fixture,
base_archive,
base_locks,
delta_archive,
delta_locks,
notify_uri,
_fallback_notify,
module_uri,
) = build_delta_replay_fixture();
let base_index = Arc::new(
ReplayArchiveIndex::load(&base_archive, &base_locks).expect("load base index"),
);
let state = RrdpState {
session_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(),
serial: 10,
};
persist_rrdp_local_state(
&store,
&notify_uri,
&state,
RrdpSourceSyncState::DeltaReady,
None,
None,
)
.expect("seed base state");
let locks_body = std::fs::read_to_string(&delta_locks).expect("read delta locks");
let rewritten = locks_body.replace("\"kind\":\"delta\"", &format!("\"kind\":\"{}\"", kind));
std::fs::write(&delta_locks, rewritten).expect("rewrite locks kind");
let repo_hash = sha256_hex(notify_uri.as_bytes());
let repo_dir = delta_archive
.join("v1/captures/delta-cap/rrdp/repos")
.join(&repo_hash);
std::fs::write(
repo_dir.join("transition.json"),
format!(
r#"{{"kind":"{kind}","base":{{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":10}},"target":{{"transport":"rrdp","session":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","serial":12}},"delta_count":2,"deltas":[11,12]}}"#,
),
)
.expect("rewrite transition kind");
let delta_index = Arc::new(
ReplayDeltaArchiveIndex::load(&delta_archive, &delta_locks).expect("load delta index"),
);
let http = PayloadDeltaReplayHttpFetcher::from_index(delta_index.clone())
.expect("build delta http fetcher");
let rsync = PayloadDeltaReplayRsyncFetcher::new(base_index.clone(), delta_index.clone());
let err = sync_publication_point_replay_delta(
&store,
&delta_index,
Some(&notify_uri),
&module_uri,
&http,
&rsync,
None,
None,
)
.unwrap_err();
assert!(matches!(err, RepoSyncError::Replay(_)), "{err}");
}
}