use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::replay::archive::{ ReplayArchiveError, ReplayRrdpRepoMeta, ReplayRsyncModuleMeta, ReplayTransport, canonical_rsync_module, sha256_hex, }; #[derive(Debug, thiserror::Error)] pub enum ReplayDeltaArchiveError { #[error(transparent)] Base(#[from] ReplayArchiveError), #[error("delta capture directory not found: {0}")] MissingDeltaCaptureDirectory(String), #[error("delta capture.json captureId mismatch: locks={locks_capture}, capture={capture_json}")] CaptureIdMismatch { locks_capture: String, capture_json: String, }, #[error( "delta base.json baseCapture mismatch: locks={locks_base_capture}, base_json={base_json_base_capture}" )] BaseCaptureMismatch { locks_base_capture: String, base_json_base_capture: String, }, #[error( "delta base.json baseLocksSha256 mismatch: locks={locks_sha256}, base_json={base_json_sha256}" )] BaseLocksShaMismatch { locks_sha256: String, base_json_sha256: String, }, #[error("base locks sha256 mismatch: expected {expected}, actual {actual}")] BaseLocksBytesShaMismatch { expected: String, actual: String }, #[error("delta repo bucket not found for {notify_uri}: {path}")] MissingDeltaRepoBucket { notify_uri: String, path: String }, #[error("delta repo meta mismatch: expected {expected}, actual {actual}")] RrdpMetaMismatch { expected: String, actual: String }, #[error( "delta transition kind mismatch for {notify_uri}: locks={locks_kind}, transition={transition_kind}" )] TransitionKindMismatch { notify_uri: String, locks_kind: String, transition_kind: String, }, #[error("delta transition base mismatch for {notify_uri}")] TransitionBaseMismatch { notify_uri: String }, #[error("delta transition target mismatch for {notify_uri}")] TransitionTargetMismatch { notify_uri: String }, #[error("delta serial list mismatch for {notify_uri}")] DeltaSerialListMismatch { notify_uri: String }, #[error("delta notification session directory not found for {notify_uri}: {path}")] MissingDeltaSessionDir { notify_uri: String, path: String }, #[error("target notification file not found for {notify_uri}: {path}")] MissingTargetNotification { notify_uri: String, path: String }, #[error("delta file not found for {notify_uri} serial={serial}: {path}")] MissingDeltaFile { notify_uri: String, serial: u64, path: String, }, #[error("delta target archive missing for {notify_uri}: {path}")] MissingTargetArchive { notify_uri: String, path: String }, #[error("delta rsync module bucket not found for {module_uri}: {path}")] MissingRsyncModuleBucket { module_uri: String, path: String }, #[error("delta rsync module meta mismatch: expected {expected}, actual {actual}")] RsyncMetaMismatch { expected: String, actual: String }, #[error("delta rsync files.json module mismatch: expected {expected}, actual {actual}")] RsyncFilesModuleMismatch { expected: String, actual: String }, #[error( "delta rsync file count mismatch for {module_uri}: declared={declared}, actual={actual}" )] RsyncFileCountMismatch { module_uri: String, declared: usize, actual: usize, }, #[error("delta rsync overlay file not found for {module_uri}: {path}")] MissingRsyncOverlayFile { module_uri: String, path: String }, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaLocks { pub version: u32, pub capture: String, #[serde(rename = "baseCapture")] pub base_capture: String, #[serde(rename = "baseLocksSha256")] pub base_locks_sha256: String, pub rrdp: BTreeMap, pub rsync: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaBaseMeta { pub version: u32, #[serde(rename = "baseCapture")] pub base_capture: String, #[serde(rename = "baseLocksSha256")] pub base_locks_sha256: String, #[serde(rename = "createdAt")] pub created_at: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ReplayDeltaRrdpKind { Unchanged, Delta, FallbackRsync, SessionReset, Gap, } impl ReplayDeltaRrdpKind { pub fn as_str(self) -> &'static str { match self { Self::Unchanged => "unchanged", Self::Delta => "delta", Self::FallbackRsync => "fallback-rsync", Self::SessionReset => "session-reset", Self::Gap => "gap", } } } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaRrdpState { pub transport: ReplayTransport, pub session: Option, pub serial: Option, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaRrdpEntry { pub kind: ReplayDeltaRrdpKind, pub base: ReplayDeltaRrdpState, pub target: ReplayDeltaRrdpState, #[serde(rename = "delta_count")] pub delta_count: usize, pub deltas: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaRsyncEntry { #[serde(rename = "file_count")] pub file_count: usize, #[serde(rename = "overlay_only")] pub overlay_only: bool, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaTransition { pub kind: ReplayDeltaRrdpKind, pub base: ReplayDeltaRrdpState, pub target: ReplayDeltaRrdpState, #[serde(rename = "delta_count")] pub delta_count: usize, pub deltas: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct ReplayDeltaRsyncFiles { pub version: u32, pub module: String, #[serde(rename = "fileCount")] pub file_count: usize, pub files: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReplayDeltaRrdpRepo { pub notify_uri: String, pub bucket_hash: String, pub bucket_dir: PathBuf, pub meta: ReplayRrdpRepoMeta, pub transition: ReplayDeltaTransition, pub target_notification_path: Option, pub delta_paths: Vec<(u64, PathBuf)>, pub target_archive_path: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReplayDeltaRsyncModule { pub module_uri: String, pub bucket_hash: String, pub bucket_dir: PathBuf, pub meta: ReplayRsyncModuleMeta, pub overlay_only: bool, pub files: ReplayDeltaRsyncFiles, pub tree_dir: PathBuf, pub overlay_files: Vec<(String, PathBuf)>, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReplayDeltaArchiveIndex { pub archive_root: PathBuf, pub capture_root: PathBuf, pub delta_locks_path: PathBuf, pub delta_locks: ReplayDeltaLocks, pub capture_meta: crate::replay::archive::ReplayCaptureMeta, pub base_meta: ReplayDeltaBaseMeta, pub rrdp_repos: BTreeMap, pub rsync_modules: BTreeMap, } impl ReplayDeltaArchiveIndex { pub fn load( delta_archive_root: impl AsRef, delta_locks_path: impl AsRef, ) -> Result { let archive_root = delta_archive_root.as_ref().to_path_buf(); let delta_locks_path = delta_locks_path.as_ref().to_path_buf(); let delta_locks: ReplayDeltaLocks = read_delta_json_file(&delta_locks_path, "payload delta locks")?; ensure_delta_version("payload delta locks", delta_locks.version)?; let capture_root = archive_root .join("v1") .join("captures") .join(&delta_locks.capture); if !capture_root.is_dir() { return Err(ReplayDeltaArchiveError::MissingDeltaCaptureDirectory( capture_root.display().to_string(), )); } let capture_meta: crate::replay::archive::ReplayCaptureMeta = read_delta_json_file(&capture_root.join("capture.json"), "delta capture meta")?; ensure_delta_version("delta capture meta", capture_meta.version)?; if capture_meta.capture_id != delta_locks.capture { return Err(ReplayDeltaArchiveError::CaptureIdMismatch { locks_capture: delta_locks.capture.clone(), capture_json: capture_meta.capture_id.clone(), }); } let base_meta: ReplayDeltaBaseMeta = read_delta_json_file(&capture_root.join("base.json"), "delta base meta")?; ensure_delta_version("delta base meta", base_meta.version)?; if base_meta.base_capture != delta_locks.base_capture { return Err(ReplayDeltaArchiveError::BaseCaptureMismatch { locks_base_capture: delta_locks.base_capture.clone(), base_json_base_capture: base_meta.base_capture.clone(), }); } if base_meta.base_locks_sha256.to_ascii_lowercase() != delta_locks.base_locks_sha256.to_ascii_lowercase() { return Err(ReplayDeltaArchiveError::BaseLocksShaMismatch { locks_sha256: delta_locks.base_locks_sha256.clone(), base_json_sha256: base_meta.base_locks_sha256.clone(), }); } let mut rrdp_repos = BTreeMap::new(); for (notify_uri, entry) in &delta_locks.rrdp { let repo = load_delta_rrdp_repo(&capture_root, notify_uri, entry)?; rrdp_repos.insert(notify_uri.clone(), repo); } let mut rsync_modules = BTreeMap::new(); for (module_uri, entry) in &delta_locks.rsync { let module = load_delta_rsync_module(&capture_root, module_uri, entry)?; rsync_modules.insert(module.module_uri.clone(), module); } Ok(Self { archive_root, capture_root, delta_locks_path, delta_locks, capture_meta, base_meta, rrdp_repos, rsync_modules, }) } pub fn rrdp_repo(&self, notify_uri: &str) -> Option<&ReplayDeltaRrdpRepo> { self.rrdp_repos.get(notify_uri) } pub fn rsync_module(&self, module_uri: &str) -> Option<&ReplayDeltaRsyncModule> { self.rsync_modules.get(module_uri) } pub fn resolve_rsync_module_for_base_uri( &self, rsync_base_uri: &str, ) -> Result<&ReplayDeltaRsyncModule, ReplayDeltaArchiveError> { let module_uri = canonical_rsync_module(rsync_base_uri).map_err(ReplayDeltaArchiveError::Base)?; self.rsync_modules.get(&module_uri).ok_or_else(|| { ReplayDeltaArchiveError::MissingRsyncModuleBucket { module_uri, path: "".to_string(), } }) } pub fn validate_base_locks_sha256_bytes( &self, base_locks_bytes: &[u8], ) -> Result<(), ReplayDeltaArchiveError> { let actual = sha256_hex(base_locks_bytes); let expected = self.delta_locks.base_locks_sha256.to_ascii_lowercase(); if actual != expected { return Err(ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { expected, actual }); } Ok(()) } pub fn validate_base_locks_sha256_file( &self, path: &Path, ) -> Result<(), ReplayDeltaArchiveError> { let bytes = fs::read(path).map_err(|e| { ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile { entity: "base locks file", path: path.display().to_string(), detail: e.to_string(), }) })?; self.validate_base_locks_sha256_bytes(&bytes) } } fn load_delta_rrdp_repo( capture_root: &Path, notify_uri: &str, entry: &ReplayDeltaRrdpEntry, ) -> Result { let bucket_hash = sha256_hex(notify_uri.as_bytes()); let bucket_dir = capture_root.join("rrdp").join("repos").join(&bucket_hash); if !bucket_dir.is_dir() { return Err(ReplayDeltaArchiveError::MissingDeltaRepoBucket { notify_uri: notify_uri.to_string(), path: bucket_dir.display().to_string(), }); } let meta: ReplayRrdpRepoMeta = read_delta_json_file(&bucket_dir.join("meta.json"), "delta RRDP repo meta")?; ensure_delta_version("delta RRDP repo meta", meta.version)?; if meta.rpki_notify != notify_uri { return Err(ReplayDeltaArchiveError::RrdpMetaMismatch { expected: notify_uri.to_string(), actual: meta.rpki_notify.clone(), }); } let transition: ReplayDeltaTransition = read_delta_json_file(&bucket_dir.join("transition.json"), "delta transition")?; if transition.kind != entry.kind { return Err(ReplayDeltaArchiveError::TransitionKindMismatch { notify_uri: notify_uri.to_string(), locks_kind: entry.kind.as_str().to_string(), transition_kind: transition.kind.as_str().to_string(), }); } if transition.base != entry.base { return Err(ReplayDeltaArchiveError::TransitionBaseMismatch { notify_uri: notify_uri.to_string(), }); } if transition.target != entry.target { return Err(ReplayDeltaArchiveError::TransitionTargetMismatch { notify_uri: notify_uri.to_string(), }); } if transition.delta_count != entry.delta_count || transition.deltas != entry.deltas { return Err(ReplayDeltaArchiveError::DeltaSerialListMismatch { notify_uri: notify_uri.to_string(), }); } let (target_notification_path, delta_paths, target_archive_path) = match entry.kind { ReplayDeltaRrdpKind::Delta => { let session = entry.target.session.as_ref().ok_or_else(|| { ReplayDeltaArchiveError::TransitionTargetMismatch { notify_uri: notify_uri.to_string(), } })?; let serial = entry.target.serial.ok_or_else(|| { ReplayDeltaArchiveError::TransitionTargetMismatch { notify_uri: notify_uri.to_string(), } })?; let session_dir = bucket_dir.join(session); if !session_dir.is_dir() { return Err(ReplayDeltaArchiveError::MissingDeltaSessionDir { notify_uri: notify_uri.to_string(), path: session_dir.display().to_string(), }); } let notification = session_dir.join(format!("notification-target-{serial}.xml")); if !notification.is_file() { return Err(ReplayDeltaArchiveError::MissingTargetNotification { notify_uri: notify_uri.to_string(), path: notification.display().to_string(), }); } let mut delta_paths = Vec::new(); for delta_serial in &entry.deltas { let pattern = format!("delta-{delta_serial}-"); let deltas_dir = session_dir.join("deltas"); let mut matches = if deltas_dir.is_dir() { fs::read_dir(&deltas_dir) .map_err(|e| { ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile { entity: "delta deltas dir", path: deltas_dir.display().to_string(), detail: e.to_string(), }) })? .filter_map(|entry| entry.ok().map(|e| e.path())) .filter(|path| path.is_file()) .filter(|path| { path.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with(&pattern) && n.ends_with(".xml")) }) .collect::>() } else { Vec::new() }; matches.sort(); let path = matches.into_iter().next().ok_or_else(|| { ReplayDeltaArchiveError::MissingDeltaFile { notify_uri: notify_uri.to_string(), serial: *delta_serial, path: deltas_dir .join(format!("delta-{delta_serial}-.xml")) .display() .to_string(), } })?; delta_paths.push((*delta_serial, path)); } let target_archive = bucket_dir.join(format!("target-archive-{serial}.bin")); let target_archive_path = if target_archive.is_file() { Some(target_archive) } else { None }; (Some(notification), delta_paths, target_archive_path) } ReplayDeltaRrdpKind::Unchanged | ReplayDeltaRrdpKind::FallbackRsync => { (None, Vec::new(), None) } ReplayDeltaRrdpKind::SessionReset | ReplayDeltaRrdpKind::Gap => (None, Vec::new(), None), }; Ok(ReplayDeltaRrdpRepo { notify_uri: notify_uri.to_string(), bucket_hash, bucket_dir, meta, transition, target_notification_path, delta_paths, target_archive_path, }) } fn load_delta_rsync_module( capture_root: &Path, module_uri: &str, entry: &ReplayDeltaRsyncEntry, ) -> Result { let canonical = canonical_rsync_module(module_uri).map_err(ReplayDeltaArchiveError::Base)?; let bucket_hash = sha256_hex(canonical.as_bytes()); let bucket_dir = capture_root .join("rsync") .join("modules") .join(&bucket_hash); if !bucket_dir.is_dir() { return Err(ReplayDeltaArchiveError::MissingRsyncModuleBucket { module_uri: canonical.clone(), path: bucket_dir.display().to_string(), }); } let meta_path = bucket_dir.join("meta.json"); let meta: ReplayRsyncModuleMeta = if meta_path.is_file() { let meta: ReplayRsyncModuleMeta = read_delta_json_file(&meta_path, "delta rsync module meta")?; ensure_delta_version("delta rsync module meta", meta.version)?; if meta.module != canonical { return Err(ReplayDeltaArchiveError::RsyncMetaMismatch { expected: canonical.clone(), actual: meta.module.clone(), }); } meta } else { ReplayRsyncModuleMeta { version: 1, module: canonical.clone(), created_at: String::new(), last_seen_at: String::new(), } }; let files: ReplayDeltaRsyncFiles = read_delta_json_file(&bucket_dir.join("files.json"), "delta rsync files")?; ensure_delta_version("delta rsync files", files.version)?; if files.module != canonical { return Err(ReplayDeltaArchiveError::RsyncFilesModuleMismatch { expected: canonical.clone(), actual: files.module.clone(), }); } if files.file_count != entry.file_count || files.file_count != files.files.len() { return Err(ReplayDeltaArchiveError::RsyncFileCountMismatch { module_uri: canonical.clone(), declared: entry.file_count, actual: files.files.len(), }); } let tree_dir = bucket_dir.join("tree"); let mut overlay_files = Vec::new(); for uri in &files.files { let rel = uri.strip_prefix(&canonical).ok_or_else(|| { ReplayDeltaArchiveError::RsyncFilesModuleMismatch { expected: canonical.clone(), actual: uri.clone(), } })?; let tree_root = module_tree_root(&canonical, &tree_dir)?; let path = tree_root.join(rel); if !path.is_file() { return Err(ReplayDeltaArchiveError::MissingRsyncOverlayFile { module_uri: canonical.clone(), path: path.display().to_string(), }); } overlay_files.push((uri.clone(), path)); } Ok(ReplayDeltaRsyncModule { module_uri: canonical, bucket_hash, bucket_dir, meta, overlay_only: entry.overlay_only, files, tree_dir, overlay_files, }) } fn module_tree_root(module_uri: &str, tree_dir: &Path) -> Result { let rest = module_uri.strip_prefix("rsync://").ok_or_else(|| { ReplayDeltaArchiveError::Base(ReplayArchiveError::InvalidRsyncUri { uri: module_uri.to_string(), detail: "URI must start with rsync://".to_string(), }) })?; let mut parts = rest.trim_end_matches('/').split('/'); let authority = parts.next().unwrap_or_default(); let module = parts.next().unwrap_or_default(); Ok(tree_dir.join(authority).join(module)) } fn ensure_delta_version(entity: &'static str, version: u32) -> Result<(), ReplayDeltaArchiveError> { if version == 1 { Ok(()) } else { Err(ReplayDeltaArchiveError::Base( ReplayArchiveError::UnsupportedVersion { entity, version }, )) } } fn read_delta_json_file Deserialize<'de>>( path: &Path, entity: &'static str, ) -> Result { let bytes = fs::read(path).map_err(|e| { ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile { entity, path: path.display().to_string(), detail: e.to_string(), }) })?; serde_json::from_slice(&bytes).map_err(|e| { ReplayDeltaArchiveError::Base(ReplayArchiveError::ParseJson { entity, path: path.display().to_string(), detail: e.to_string(), }) }) } #[cfg(test)] mod tests { use super::*; fn build_delta_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, String) { let temp = tempfile::tempdir().expect("tempdir"); let archive_root = temp.path().join("payload-delta-archive"); let capture = "delta-cap"; let base_capture = "base-cap"; let base_sha = "deadbeef"; 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-15T00:00:00Z","notes":""}}"# ), ) .expect("write capture json"); std::fs::write( capture_root.join("base.json"), format!( r#"{{"version":1,"baseCapture":"{base_capture}","baseLocksSha256":"{base_sha}","createdAt":"2026-03-15T00:00:00Z"}}"# ), ) .expect("write base json"); let notify_uri = "https://rrdp.example.test/notification.xml".to_string(); let session = "11111111-1111-1111-1111-111111111111".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"); std::fs::write( session_dir.parent().unwrap().join("meta.json"), format!( r#"{{"version":1,"rpkiNotify":"{notify_uri}","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}}"# ), ) .expect("write meta"); std::fs::write( 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 transition"); std::fs::write( session_dir.join("notification-target-12.xml"), b"", ) .expect("write notification"); std::fs::write( deltas_dir.join("delta-11-aaaa.xml"), b"", ) .expect("write delta 11"); std::fs::write( deltas_dir.join("delta-12-bbbb.xml"), b"", ) .expect("write delta 12"); std::fs::write( session_dir.parent().unwrap().join("target-archive-12.bin"), b"bin", ) .expect("write target archive"); let module_uri = "rsync://rsync.example.test/repo/".to_string(); let module_hash = sha256_hex(module_uri.as_bytes()); let module_bucket = capture_root.join("rsync/modules").join(&module_hash); let tree_root = module_bucket .join("tree") .join("rsync.example.test") .join("repo"); std::fs::create_dir_all(tree_root.join("sub")).expect("mkdir tree root"); std::fs::write( module_bucket.join("meta.json"), format!( r#"{{"version":1,"module":"{module_uri}","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}}"# ), ) .expect("write rsync meta"); std::fs::write( module_bucket.join("files.json"), format!( r#"{{"version":1,"module":"{module_uri}","fileCount":2,"files":["{module_uri}a.roa","{module_uri}sub/b.cer"]}}"# ), ) .expect("write files json"); std::fs::write(tree_root.join("a.roa"), b"roa").expect("write a.roa"); std::fs::write(tree_root.join("sub").join("b.cer"), b"cer").expect("write b.cer"); let locks_path = temp.path().join("locks-delta.json"); std::fs::write( &locks_path, format!( r#"{{"version":1,"capture":"{capture}","baseCapture":"{base_capture}","baseLocksSha256":"{base_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]}}}},"rsync":{{"{module_uri}":{{"file_count":2,"overlay_only":true}}}}}}"# ), ) .expect("write locks-delta"); (temp, archive_root, locks_path, notify_uri, module_uri) } #[test] fn delta_archive_index_loads_unchanged_and_fallback_rsync_rrdp_entries() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let capture_root = archive_root.join("v1/captures/delta-cap"); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = capture_root.join("rrdp/repos").join(&repo_hash); std::fs::write( repo_dir.join("transition.json"), r#"{"kind":"unchanged","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"delta_count":0,"deltas":[]}"#, ) .expect("rewrite transition unchanged"); std::fs::write( &locks_path, r#"{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{"https://rrdp.example.test/notification.xml":{"kind":"unchanged","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"delta_count":0,"deltas":[]}},"rsync":{"rsync://rsync.example.test/repo/":{"file_count":2,"overlay_only":true}}}"#, ).expect("rewrite locks unchanged"); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path) .expect("load unchanged index"); let repo = index.rrdp_repo(¬ify_uri).expect("rrdp repo"); assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::Unchanged); assert!(repo.target_notification_path.is_none()); assert!(repo.delta_paths.is_empty()); assert!(repo.target_archive_path.is_none()); std::fs::write( 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("rewrite transition fallback-rsync"); std::fs::write( &locks_path, r#"{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{"https://rrdp.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":2,"overlay_only":true}}}"#, ).expect("rewrite locks fallback-rsync"); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path) .expect("load fallback-rsync index"); let repo = index.rrdp_repo(¬ify_uri).expect("rrdp repo"); assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::FallbackRsync); assert!(repo.target_notification_path.is_none()); assert!(repo.delta_paths.is_empty()); } #[test] fn delta_archive_index_resolves_rsync_module_from_base_uri() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"); let module = index .resolve_rsync_module_for_base_uri("rsync://rsync.example.test/repo/sub/path") .expect("resolve module"); assert_eq!(module.module_uri, "rsync://rsync.example.test/repo/"); } #[test] fn delta_archive_index_rejects_unsupported_versions_and_meta_mismatches() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); std::fs::write( &locks_path, r#"{"version":2,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{},"rsync":{}}"#, ).expect("rewrite locks version"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::Base(ReplayArchiveError::UnsupportedVersion { entity: "payload delta locks", version: 2 }) ), "{err}" ); let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); std::fs::write( archive_root.join("v1/captures/delta-cap/rrdp/repos").join(&repo_hash).join("meta.json"), r#"{"version":1,"rpkiNotify":"https://other.example/notification.xml","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}"#, ).expect("rewrite rrdp meta"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::RrdpMetaMismatch { .. }), "{err}" ); let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture(); let module_hash = sha256_hex(module_uri.as_bytes()); std::fs::write( archive_root.join("v1/captures/delta-cap/rsync/modules").join(&module_hash).join("meta.json"), r#"{"version":1,"module":"rsync://other.example/repo/","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}"#, ).expect("rewrite rsync meta"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::RsyncMetaMismatch { .. }), "{err}" ); } #[test] fn delta_archive_index_rejects_transition_base_target_and_serial_mismatches() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash); std::fs::write( repo_dir.join("transition.json"), r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":9},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":2,"deltas":[11,12]}"#, ).expect("rewrite transition base"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::TransitionBaseMismatch { .. }), "{err}" ); let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash); std::fs::write( repo_dir.join("transition.json"), r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":13},"delta_count":2,"deltas":[11,12]}"#, ).expect("rewrite transition target"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::TransitionTargetMismatch { .. } ), "{err}" ); let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash); std::fs::write( repo_dir.join("transition.json"), r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":1,"deltas":[12]}"#, ).expect("rewrite transition deltas"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::DeltaSerialListMismatch { .. }), "{err}" ); } #[test] fn delta_archive_index_rejects_missing_session_dir_and_overlay_files() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let session_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash) .join("11111111-1111-1111-1111-111111111111"); std::fs::remove_dir_all(&session_dir).expect("remove session dir"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::MissingDeltaSessionDir { .. }), "{err}" ); let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture(); let module_hash = sha256_hex(module_uri.as_bytes()); let overlay_path = archive_root .join("v1/captures/delta-cap/rsync/modules") .join(&module_hash) .join("tree/rsync.example.test/repo/sub/b.cer"); std::fs::remove_file(overlay_path).expect("remove overlay file"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::MissingRsyncOverlayFile { .. }), "{err}" ); } #[test] fn delta_archive_index_accepts_missing_rsync_module_meta_when_files_and_tree_exist() { let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture(); let module_hash = sha256_hex(module_uri.as_bytes()); let meta_path = archive_root .join("v1/captures/delta-cap/rsync/modules") .join(module_hash) .join("meta.json"); std::fs::remove_file(&meta_path).expect("remove delta rsync module meta"); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path) .expect("load delta replay index without rsync meta"); let module = index .rsync_modules .get(&module_uri) .expect("module present"); assert_eq!(module.meta.module, module_uri); assert_eq!(module.meta.version, 1); } #[test] fn delta_archive_index_accepts_correct_base_locks_sha_and_rejects_missing_module_resolution() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"); let locks_bytes = std::fs::read(&locks_path).expect("read locks bytes"); assert!( index .validate_base_locks_sha256_bytes(&locks_bytes) .is_err() ); let err = index .resolve_rsync_module_for_base_uri("rsync://missing.example/repo/path") .unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::MissingRsyncModuleBucket { .. } ), "{err}" ); } #[test] fn delta_archive_index_loads_session_reset_and_gap_entries_without_target_files() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash); for kind in ["session-reset", "gap"] { std::fs::write( repo_dir.join("transition.json"), format!( r#"{{"kind":"{kind}","base":{{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10}},"target":{{"transport":"rrdp","session":"22222222-2222-2222-2222-222222222222","serial":12}},"delta_count":0,"deltas":[]}}"#, ), ) .expect("rewrite transition kind"); std::fs::write( &locks_path, format!( r#"{{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{{"{notify_uri}":{{"kind":"{kind}","base":{{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10}},"target":{{"transport":"rrdp","session":"22222222-2222-2222-2222-222222222222","serial":12}},"delta_count":0,"deltas":[]}}}},"rsync":{{"rsync://rsync.example.test/repo/":{{"file_count":2,"overlay_only":true}}}}}}"#, ), ) .expect("rewrite locks kind"); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path) .expect("load delta index"); let repo = index.rrdp_repo(¬ify_uri).expect("rrdp repo"); assert!(repo.target_notification_path.is_none()); assert!(repo.delta_paths.is_empty()); } } #[test] fn delta_archive_index_loads_rrdp_and_rsync_entries() { let (_temp, archive_root, locks_path, notify_uri, module_uri) = build_delta_fixture(); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"); assert_eq!(index.capture_meta.capture_id, "delta-cap"); assert_eq!(index.base_meta.base_capture, "base-cap"); assert_eq!(index.rrdp_repos.len(), 1); assert_eq!(index.rsync_modules.len(), 1); let repo = index.rrdp_repo(¬ify_uri).expect("rrdp repo"); assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::Delta); assert_eq!(repo.transition.delta_count, 2); assert_eq!(repo.delta_paths.len(), 2); assert!(repo.target_notification_path.as_ref().unwrap().is_file()); assert!(repo.target_archive_path.as_ref().unwrap().is_file()); let module = index.rsync_module(&module_uri).expect("rsync module"); assert_eq!(module.files.file_count, 2); assert_eq!(module.overlay_files.len(), 2); assert!(module.overlay_files.iter().all(|(_, path)| path.is_file())); } #[test] fn delta_archive_index_rejects_capture_and_sha_mismatches() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let capture_root = archive_root.join("v1/captures/delta-cap"); std::fs::write( capture_root.join("capture.json"), r#"{"version":1,"captureId":"other-cap","createdAt":"2026-03-15T00:00:00Z","notes":""}"#, ) .expect("rewrite capture json"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::CaptureIdMismatch { .. }), "{err}" ); let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let capture_root = archive_root.join("v1/captures/delta-cap"); std::fs::write( capture_root.join("base.json"), r#"{"version":1,"baseCapture":"base-cap","baseLocksSha256":"beefdead","createdAt":"2026-03-15T00:00:00Z"}"#, ) .expect("rewrite base json sha"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::BaseLocksShaMismatch { .. }), "{err}" ); } #[test] fn delta_archive_index_rejects_missing_target_notification_and_repo_bucket() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let session_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash) .join("11111111-1111-1111-1111-111111111111"); std::fs::remove_file(session_dir.join("notification-target-12.xml")) .expect("remove target notification"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::MissingTargetNotification { .. } ), "{err}" ); let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash); std::fs::remove_dir_all(repo_dir).expect("remove repo dir"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::MissingDeltaRepoBucket { .. }), "{err}" ); } #[test] fn delta_archive_index_rejects_base_meta_mismatch() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let capture_root = archive_root.join("v1/captures/delta-cap"); std::fs::write( capture_root.join("base.json"), r#"{"version":1,"baseCapture":"other","baseLocksSha256":"deadbeef","createdAt":"2026-03-15T00:00:00Z"}"#, ) .expect("rewrite base json"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::BaseCaptureMismatch { .. }), "{err}" ); } #[test] fn delta_archive_index_rejects_transition_mismatch_and_missing_delta_file() { let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let repo_dir = archive_root .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":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":2,"deltas":[11,12]}"#, ) .expect("rewrite transition"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::TransitionKindMismatch { .. }), "{err}" ); let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture(); let repo_hash = sha256_hex(notify_uri.as_bytes()); let delta_path = archive_root .join("v1/captures/delta-cap/rrdp/repos") .join(&repo_hash) .join("11111111-1111-1111-1111-111111111111/deltas/delta-12-bbbb.xml"); std::fs::remove_file(delta_path).expect("remove delta"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::MissingDeltaFile { .. }), "{err}" ); } #[test] fn delta_archive_index_validates_base_locks_sha256_bytes_and_file() { let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture(); let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index"); let err = index .validate_base_locks_sha256_bytes(b"not-the-right-base-locks") .unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { .. } ), "{err}" ); let temp_file = tempfile::NamedTempFile::new().expect("tempfile"); std::fs::write(temp_file.path(), b"still-wrong").expect("write base locks file"); let err = index .validate_base_locks_sha256_file(temp_file.path()) .unwrap_err(); assert!( matches!( err, ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { .. } ), "{err}" ); } #[test] fn delta_archive_index_rejects_rsync_files_mismatch() { let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture(); let module_hash = sha256_hex(module_uri.as_bytes()); let module_dir = archive_root .join("v1/captures/delta-cap/rsync/modules") .join(&module_hash); std::fs::write( module_dir.join("files.json"), format!( r#"{{"version":1,"module":"{module_uri}","fileCount":3,"files":["{module_uri}a.roa"]}}"# ), ) .expect("rewrite files json"); let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err(); assert!( matches!(err, ReplayDeltaArchiveError::RsyncFileCountMismatch { .. }), "{err}" ); } }