use std::cell::RefCell; use std::collections::HashMap; use std::path::PathBuf; use std::time::{Duration, Instant}; use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig}; use rpki::fetch::rsync::{RsyncFetchError, RsyncFetcher}; use rpki::policy::{CaFailedFetchPolicy, Policy, SyncPreference}; use rpki::storage::{FetchCachePpKey, RocksStore}; use rpki::sync::repo::{RepoSyncSource, sync_publication_point}; use rpki::sync::rrdp::{Fetcher, parse_notification, sync_from_notification}; use rpki::validation::from_tal::discover_root_ca_instance_from_tal_url; use rpki::validation::manifest::{PublicationPointSource, process_manifest_publication_point}; const APNIC_TAL_URL: &str = "https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal"; fn persistent_db_dir() -> PathBuf { if let Ok(s) = std::env::var("RPKI_LIVE_DB_DIR") { return PathBuf::from(s); } PathBuf::from("target/live/apnic_rrdp_db") } fn live_http_fetcher() -> BlockingHttpFetcher { let timeout_secs: u64 = std::env::var("RPKI_LIVE_HTTP_TIMEOUT_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(15 * 60); BlockingHttpFetcher::new(HttpFetcherConfig { timeout: Duration::from_secs(timeout_secs), user_agent: "rpki-dev/0.1 (stage2 live rrdp delta test)".to_string(), }) .expect("http fetcher") } struct AlwaysFailRsyncFetcher; impl RsyncFetcher for AlwaysFailRsyncFetcher { fn fetch_objects( &self, _rsync_base_uri: &str, ) -> Result)>, RsyncFetchError> { Err(RsyncFetchError::Fetch( "rsync disabled for this test".to_string(), )) } } #[derive(Clone)] struct CountingDenyUriFetcher { inner: BlockingHttpFetcher, deny_uri: String, counts: std::rc::Rc>>, } impl CountingDenyUriFetcher { fn new(inner: BlockingHttpFetcher, deny_uri: String) -> Self { Self { inner, deny_uri, counts: std::rc::Rc::new(RefCell::new(HashMap::new())), } } fn count(&self, uri: &str) -> u64 { *self.counts.borrow().get(uri).unwrap_or(&0) } } impl Fetcher for CountingDenyUriFetcher { fn fetch(&self, uri: &str) -> Result, String> { *self.counts.borrow_mut().entry(uri.to_string()).or_insert(0) += 1; if uri == self.deny_uri { return Err(format!("snapshot fetch denied: {uri}")); } self.inner.fetch(uri) } } fn live_policy() -> Policy { let mut p = Policy::default(); p.sync_preference = SyncPreference::RrdpThenRsync; p.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; p } #[test] #[ignore = "live network: APNIC RRDP snapshot bootstrap into persistent RocksDB"] fn apnic_live_bootstrap_snapshot_and_fetch_cache_pp_pack_to_persistent_db() { let http = live_http_fetcher(); let rsync = AlwaysFailRsyncFetcher; let db_dir = persistent_db_dir(); std::fs::create_dir_all(&db_dir).expect("create db dir"); let store = RocksStore::open(&db_dir).expect("open rocksdb"); let policy = live_policy(); let validation_time = time::OffsetDateTime::now_utc(); let discovery = discover_root_ca_instance_from_tal_url(&http, APNIC_TAL_URL) .expect("discover root CA instance from APNIC TAL"); let ca_instance = discovery.ca_instance; let rrdp_notification_uri = ca_instance .rrdp_notification_uri .as_deref() .expect("APNIC root must have rrdpNotification"); let sync = sync_publication_point( &store, &policy, Some(rrdp_notification_uri), &ca_instance.rsync_base_uri, &http, &rsync, None, ) .expect("repo sync"); assert_eq!(sync.source, RepoSyncSource::Rrdp); // Build + persist a fetch_cache_pp pack for the root publication point so later runs can // validate behavior under failed fetch conditions (RFC 9286 §6.6). let ta_der = discovery.trust_anchor.ta_certificate.raw_der; let pp = process_manifest_publication_point( &store, &policy, &ca_instance.manifest_rsync_uri, &ca_instance.publication_point_rsync_uri, &ta_der, None, validation_time, ) .expect("process manifest publication point"); assert_eq!(pp.source, PublicationPointSource::Fresh); let key = FetchCachePpKey::from_manifest_rsync_uri(&ca_instance.manifest_rsync_uri); let cached = store.get_fetch_cache_pp(&key).expect("get fetch_cache_pp"); assert!(cached.is_some(), "expected fetch_cache_pp to be stored"); eprintln!( "OK: bootstrap complete; persistent db at: {}", db_dir.display() ); eprintln!( "Next: run `cargo test --release -q --test test_apnic_rrdp_delta_live_20260226 -- --ignored` later to exercise delta sync." ); } #[test] #[ignore = "live network: waits for APNIC RRDP serial advance, then sync via deltas only (no snapshot) using persistent RocksDB"] fn apnic_live_delta_only_from_persistent_db() { let http = live_http_fetcher(); let db_dir = persistent_db_dir(); let store = RocksStore::open(&db_dir).expect("open rocksdb (must have been bootstrapped)"); let policy = live_policy(); let discovery = discover_root_ca_instance_from_tal_url(&http, APNIC_TAL_URL) .expect("discover root CA instance from APNIC TAL"); let ca_instance = discovery.ca_instance; let rrdp_notification_uri = ca_instance .rrdp_notification_uri .as_deref() .expect("APNIC root must have rrdpNotification"); let state_bytes = store .get_rrdp_state(rrdp_notification_uri) .expect("get rrdp_state") .unwrap_or_else(|| { panic!( "missing rrdp_state for APNIC notification URI; run bootstrap test first. db_dir={}", db_dir.display() ) }); let state = rpki::sync::rrdp::RrdpState::decode(&state_bytes).expect("decode rrdp_state"); let old_serial = state.serial; let old_session = state.session_id; let max_wait_secs: u64 = std::env::var("RPKI_LIVE_MAX_WAIT_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(30 * 60); let poll_secs: u64 = std::env::var("RPKI_LIVE_POLL_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(60); let start = Instant::now(); loop { if start.elapsed() > Duration::from_secs(max_wait_secs) { panic!( "timed out waiting for APNIC RRDP serial to advance for delta sync; old_session={} old_serial={} waited={}s", old_session, old_serial, max_wait_secs ); } let notif_xml = http .fetch(rrdp_notification_uri) .unwrap_or_else(|e| panic!("fetch notification failed: {e}")); let notif = parse_notification(¬if_xml).expect("parse notification"); if notif.session_id.to_string() != old_session { panic!( "RRDP session_id changed; this delta-only test assumes same snapshot baseline. old_session={} new_session={}", old_session, notif.session_id ); } if notif.serial <= old_serial { eprintln!( "waiting for serial advance: session={} old_serial={} current_serial={}", old_session, old_serial, notif.serial ); std::thread::sleep(Duration::from_secs(poll_secs)); continue; } let want_first = old_serial + 1; let min_delta = notif.deltas.first().map(|d| d.serial).unwrap_or(u64::MAX); if notif.deltas.is_empty() || min_delta > want_first { panic!( "notification deltas do not cover required serial gap for delta-only sync; old_serial={} want_first={} min_delta={} current_serial={}. rerun bootstrap to refresh snapshot baseline.", old_serial, want_first, min_delta, notif.serial ); } // Deny snapshot fetch to ensure we truly test the delta path and keep the stored snapshot // baseline unchanged. let deny = notif.snapshot_uri.clone(); let fetcher = CountingDenyUriFetcher::new(http.clone(), deny.clone()); match sync_from_notification(&store, rrdp_notification_uri, ¬if_xml, &fetcher) { Ok(written) => { assert!( written > 0, "expected delta sync to apply changes (written={written})" ); assert_eq!( fetcher.count(&deny), 0, "delta sync should not fetch snapshot" ); eprintln!( "OK: delta sync applied: written={} old_serial={} new_serial={}", written, old_serial, notif.serial ); break; } Err(e) => { eprintln!("delta sync attempt failed (will retry): {e}"); std::thread::sleep(Duration::from_secs(poll_secs)); } } } // Keep policy variable used, to avoid warnings if this test evolves. let _ = policy; } #[test] #[ignore = "offline/synthetic: after bootstrap, force repo sync failure and assert fetch_cache_pp is used (RFC 9286 §6.6)"] fn apnic_root_repo_sync_failure_uses_fetch_cache_pp_pack() { let http = live_http_fetcher(); let db_dir = persistent_db_dir(); let store = RocksStore::open(&db_dir).expect("open rocksdb (must have been bootstrapped)"); let mut policy = live_policy(); policy.sync_preference = SyncPreference::RrdpThenRsync; policy.ca_failed_fetch_policy = CaFailedFetchPolicy::UseFetchCachePp; let validation_time = time::OffsetDateTime::now_utc(); let discovery = discover_root_ca_instance_from_tal_url(&http, APNIC_TAL_URL) .expect("discover root CA instance from APNIC TAL"); let ca_instance = discovery.ca_instance; // Ensure cache exists (created by bootstrap). let key = FetchCachePpKey::from_manifest_rsync_uri(&ca_instance.manifest_rsync_uri); let cached = store.get_fetch_cache_pp(&key).expect("get fetch_cache_pp"); assert!( cached.is_some(), "missing fetch_cache_pp; run bootstrap test first. db_dir={}", db_dir.display() ); // Simulate repo sync failure: skip calling sync_publication_point and directly drive manifest // processing with repo_sync_ok=false. let ta_der = discovery.trust_anchor.ta_certificate.raw_der; let pp = rpki::validation::manifest::process_manifest_publication_point_after_repo_sync( &store, &policy, &ca_instance.manifest_rsync_uri, &ca_instance.publication_point_rsync_uri, &ta_der, None, validation_time, false, Some("synthetic repo sync failure"), ) .expect("must fall back to fetch_cache_pp"); assert_eq!(pp.source, PublicationPointSource::FetchCachePp); assert!( pp.warnings .iter() .any(|w| w.message.contains("using fetch_cache_pp")), "expected cache-use warning" ); }