use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use rpki::ccr::{ CcrContentInfo, VapCompareRow, VrpCompareRow, compare_state_digests, decode_ccr_compare_views, decode_content_info, write_vap_csv, write_vrp_csv, }; use serde_json::json; #[derive(Debug, Default, PartialEq, Eq)] struct Args { ours_ccr: Option, peer_ccr: Option, out_json: Option, out_md: Option, out_dir: Option, trust_anchor: String, fallback_compare_views: bool, } fn usage() -> &'static str { "Usage: ccr_state_compare --ours-ccr --rpki-client-ccr --out-json [--out-md ] [--out-dir ] [--trust-anchor ] [--fallback-compare-views]" } fn parse_args(argv: &[String]) -> Result { let mut args = Args { trust_anchor: "unknown".to_string(), ..Args::default() }; let mut i = 1usize; while i < argv.len() { match argv[i].as_str() { "--ours-ccr" => { i += 1; args.ours_ccr = Some(argv.get(i).ok_or("--ours-ccr requires a value")?.into()); } "--rpki-client-ccr" | "--peer-ccr" => { i += 1; args.peer_ccr = Some( argv.get(i) .ok_or("--rpki-client-ccr requires a value")? .into(), ); } "--out-json" => { i += 1; args.out_json = Some(argv.get(i).ok_or("--out-json requires a value")?.into()); } "--out-md" => { i += 1; args.out_md = Some(argv.get(i).ok_or("--out-md requires a value")?.into()); } "--out-dir" => { i += 1; args.out_dir = Some(argv.get(i).ok_or("--out-dir requires a value")?.into()); } "--trust-anchor" => { i += 1; args.trust_anchor = argv .get(i) .ok_or("--trust-anchor requires a value")? .clone(); } "--fallback-compare-views" => { args.fallback_compare_views = true; } "-h" | "--help" => return Err(usage().to_string()), other => return Err(format!("unknown argument: {other}\n{}", usage())), } i += 1; } if args.ours_ccr.is_none() { return Err(format!("--ours-ccr is required\n{}", usage())); } if args.peer_ccr.is_none() { return Err(format!("--rpki-client-ccr is required\n{}", usage())); } if args.out_json.is_none() { return Err(format!("--out-json is required\n{}", usage())); } Ok(args) } fn main() -> Result<(), String> { let args = parse_args(&std::env::args().collect::>())?; run(args) } fn run(args: Args) -> Result<(), String> { let ours_path = args.ours_ccr.as_ref().unwrap(); let peer_path = args.peer_ccr.as_ref().unwrap(); let ours_der = read_file(ours_path)?; let peer_der = read_file(peer_path)?; let comparison = compare_state_digests(&ours_der, &peer_der).map_err(|e| e.to_string())?; let state_digest_match = comparison.matches(); let mismatched_states = comparison.mismatched_state_names(); let mut mismatched_components = Vec::new(); if comparison.ours.version != comparison.peer.version { mismatched_components.push("version".to_string()); } if comparison.ours.hash_alg_oid != comparison.peer.hash_alg_oid { mismatched_components.push("hashAlgorithm".to_string()); } mismatched_components.extend(mismatched_states.iter().map(|name| (*name).to_string())); let run_fallback = args.fallback_compare_views && !state_digest_match; let fallback = if run_fallback { Some(build_compare_view_fallback( &ours_der, &peer_der, &args.trust_anchor, args.out_dir.as_deref(), )?) } else { None }; let all_match = state_digest_match; let compare_path = if state_digest_match { "ccr_state_digest_match" } else if let Some(fallback) = fallback.as_ref() { if fallback.vrps.match_ && fallback.vaps.match_ { "ccr_state_digest_mismatch_with_compare_views_match" } else { "ccr_state_digest_mismatch_with_set_diff" } } else { "ccr_state_digest_mismatch" }; let summary = json!({ "comparePath": compare_path, "allMatch": all_match, "stateDigestMatch": state_digest_match, "mismatchedStates": mismatched_states, "mismatchedComponents": mismatched_components, "versionMatch": comparison.ours.version == comparison.peer.version, "hashAlgorithmMatch": comparison.ours.hash_alg_oid == comparison.peer.hash_alg_oid, "ours": { "version": comparison.ours.version, "hashAlg": comparison.ours.hash_alg_oid, }, "rpkiClient": { "version": comparison.peer.version, "hashAlg": comparison.peer.hash_alg_oid, }, "states": comparison.states.iter().map(|state| json!({ "name": state.name, "match": state.matches, "oursPresent": state.ours_present, "rpkiClientPresent": state.peer_present, "oursHash": state.ours_hash_hex, "rpkiClientHash": state.peer_hash_hex, })).collect::>(), "vrps": fallback.as_ref().map(|summary| summary.vrps.to_json()).unwrap_or_else(|| json!({ "ours": serde_json::Value::Null, "rpkiClient": serde_json::Value::Null, "match": state_digest_match || !mismatched_states.contains(&"vrps"), "onlyInOurs": [], "onlyInRpkiClient": [], })), "vaps": fallback.as_ref().map(|summary| summary.vaps.to_json()).unwrap_or_else(|| json!({ "ours": serde_json::Value::Null, "rpkiClient": serde_json::Value::Null, "match": state_digest_match || !mismatched_states.contains(&"vaps"), "onlyInOurs": [], "onlyInRpkiClient": [], })), }); write_json(args.out_json.as_ref().unwrap(), &summary)?; if let Some(md_path) = args.out_md.as_ref() { write_markdown(md_path, &summary)?; } println!("{}", args.out_json.as_ref().unwrap().display()); Ok(()) } fn read_file(path: &Path) -> Result, String> { std::fs::read(path).map_err(|e| format!("read file failed: {}: {e}", path.display())) } fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?; } std::fs::write( path, serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?, ) .map_err(|e| format!("write json failed: {}: {e}", path.display())) } fn write_markdown(path: &Path, summary: &serde_json::Value) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?; } let lines = vec![ "# CCR State Compare Summary".to_string(), String::new(), format!( "- `comparePath`: `{}`", summary["comparePath"].as_str().unwrap_or("-") ), format!( "- `allMatch`: `{}`", summary["allMatch"].as_bool().unwrap_or(false) ), format!( "- `stateDigestMatch`: `{}`", summary["stateDigestMatch"].as_bool().unwrap_or(false) ), format!( "- `mismatchedStates`: `{}`", summary["mismatchedStates"] .as_array() .map(|items| { items .iter() .filter_map(|item| item.as_str()) .collect::>() .join(",") }) .unwrap_or_default() ), format!( "- `vrpMatch`: `{}`", summary["vrps"]["match"].as_bool().unwrap_or(false) ), format!( "- `vapMatch`: `{}`", summary["vaps"]["match"].as_bool().unwrap_or(false) ), ]; std::fs::write(path, lines.join("\n") + "\n") .map_err(|e| format!("write markdown failed: {}: {e}", path.display())) } struct FallbackSummary { vrps: SetSummary, vaps: SetSummary, } struct SetSummary { ours: usize, rpki_client: usize, match_: bool, only_in_ours: Vec, only_in_rpki_client: Vec, } impl SetSummary { fn to_json(&self) -> serde_json::Value { json!({ "ours": self.ours, "rpkiClient": self.rpki_client, "match": self.match_, "onlyInOurs": self.only_in_ours.iter().map(vrp_row_json).collect::>(), "onlyInRpkiClient": self.only_in_rpki_client.iter().map(vrp_row_json).collect::>(), }) } } impl SetSummary { fn to_json(&self) -> serde_json::Value { json!({ "ours": self.ours, "rpkiClient": self.rpki_client, "match": self.match_, "onlyInOurs": self.only_in_ours.iter().map(vap_row_json).collect::>(), "onlyInRpkiClient": self.only_in_rpki_client.iter().map(vap_row_json).collect::>(), }) } } fn build_compare_view_fallback( ours_der: &[u8], peer_der: &[u8], trust_anchor: &str, out_dir: Option<&Path>, ) -> Result { let ours = decode_content_info(ours_der).map_err(|e| e.to_string())?; let peer = decode_content_info(peer_der).map_err(|e| e.to_string())?; let (ours_vrps, ours_vaps) = decode_views(&ours, trust_anchor)?; let (peer_vrps, peer_vaps) = decode_views(&peer, trust_anchor)?; if let Some(out_dir) = out_dir { write_vrp_csv(&out_dir.join("ours-vrps.csv"), &ours_vrps)?; write_vap_csv(&out_dir.join("ours-vaps.csv"), &ours_vaps)?; write_vrp_csv(&out_dir.join("rpki-client-vrps.csv"), &peer_vrps)?; write_vap_csv(&out_dir.join("rpki-client-vaps.csv"), &peer_vaps)?; } Ok(FallbackSummary { vrps: compare_sets(&ours_vrps, &peer_vrps), vaps: compare_sets(&ours_vaps, &peer_vaps), }) } fn decode_views( content_info: &CcrContentInfo, trust_anchor: &str, ) -> Result<(BTreeSet, BTreeSet), String> { decode_ccr_compare_views(content_info, trust_anchor) } fn compare_sets(ours: &BTreeSet, rpki_client: &BTreeSet) -> SetSummary { SetSummary { ours: ours.len(), rpki_client: rpki_client.len(), match_: ours == rpki_client, only_in_ours: ours.difference(rpki_client).take(20).cloned().collect(), only_in_rpki_client: rpki_client.difference(ours).take(20).cloned().collect(), } } fn vrp_row_json(row: &VrpCompareRow) -> serde_json::Value { json!([row.asn, row.ip_prefix, row.max_length, row.trust_anchor]) } fn vap_row_json(row: &VapCompareRow) -> serde_json::Value { json!([row.customer_asn, row.providers, row.trust_anchor]) } #[cfg(test)] mod tests { use super::*; use rpki::ccr::{ CcrContentInfo, CcrDigestAlgorithm, ManifestState, RpkiCanonicalCacheRepresentation, build_aspa_payload_state, build_roa_payload_state, encode_content_info, }; use rpki::data_model::roa::{IpPrefix, RoaAfi}; use rpki::validation::objects::{AspaAttestation, Vrp}; #[test] fn parse_args_accepts_required_flags() { let args = parse_args(&[ "ccr_state_compare".to_string(), "--ours-ccr".to_string(), "ours.ccr".to_string(), "--rpki-client-ccr".to_string(), "peer.ccr".to_string(), "--out-json".to_string(), "summary.json".to_string(), "--fallback-compare-views".to_string(), ]) .expect("parse args"); assert_eq!(args.ours_ccr.as_deref(), Some(Path::new("ours.ccr"))); assert_eq!(args.peer_ccr.as_deref(), Some(Path::new("peer.ccr"))); assert_eq!(args.out_json.as_deref(), Some(Path::new("summary.json"))); assert!(args.fallback_compare_views); } #[test] fn parse_args_rejects_missing_peer() { let err = parse_args(&[ "ccr_state_compare".to_string(), "--ours-ccr".to_string(), "ours.ccr".to_string(), "--out-json".to_string(), "summary.json".to_string(), ]) .unwrap_err(); assert!(err.contains("--rpki-client-ccr is required"), "{err}"); } #[test] fn run_reports_digest_match_without_fallback_counts() { let temp = tempfile::tempdir().expect("tempdir"); let ccr = encode_content_info(&sample_content(64496)).expect("encode"); let ours = temp.path().join("ours.ccr"); let peer = temp.path().join("peer.ccr"); let out = temp.path().join("summary.json"); std::fs::write(&ours, &ccr).expect("write ours"); std::fs::write(&peer, &ccr).expect("write peer"); run(Args { ours_ccr: Some(ours), peer_ccr: Some(peer), out_json: Some(out.clone()), out_md: None, out_dir: Some(temp.path().join("compare")), trust_anchor: "apnic".to_string(), fallback_compare_views: true, }) .expect("run"); let summary: serde_json::Value = serde_json::from_slice(&std::fs::read(out).expect("read summary")).expect("json"); assert_eq!(summary["comparePath"], "ccr_state_digest_match"); assert_eq!(summary["allMatch"], true); assert!(summary["vrps"]["ours"].is_null()); assert!(!temp.path().join("compare/ours-vrps.csv").exists()); } #[test] fn run_reports_digest_mismatch_with_vrp_fallback() { let temp = tempfile::tempdir().expect("tempdir"); let ours_ccr = encode_content_info(&sample_content(64496)).expect("encode ours"); let peer_ccr = encode_content_info(&sample_content(64497)).expect("encode peer"); let ours = temp.path().join("ours.ccr"); let peer = temp.path().join("peer.ccr"); let out = temp.path().join("summary.json"); std::fs::write(&ours, &ours_ccr).expect("write ours"); std::fs::write(&peer, &peer_ccr).expect("write peer"); run(Args { ours_ccr: Some(ours), peer_ccr: Some(peer), out_json: Some(out.clone()), out_md: None, out_dir: Some(temp.path().join("compare")), trust_anchor: "apnic".to_string(), fallback_compare_views: true, }) .expect("run"); let summary: serde_json::Value = serde_json::from_slice(&std::fs::read(out).expect("read summary")).expect("json"); assert_eq!( summary["comparePath"], "ccr_state_digest_mismatch_with_set_diff" ); assert_eq!(summary["allMatch"], false); assert_eq!(summary["vrps"]["ours"], 1); assert_eq!(summary["vrps"]["rpkiClient"], 1); assert_eq!(summary["vrps"]["match"], false); assert!(temp.path().join("compare/ours-vrps.csv").exists()); } #[test] fn run_reports_digest_mismatch_but_compare_views_match() { let temp = tempfile::tempdir().expect("tempdir"); let ours_ccr = encode_content_info(&sample_content_with_manifest_hash(64496, 0x11)) .expect("encode ours"); let peer_ccr = encode_content_info(&sample_content_with_manifest_hash(64496, 0x22)) .expect("encode peer"); let ours = temp.path().join("ours.ccr"); let peer = temp.path().join("peer.ccr"); let out = temp.path().join("nested/summary.json"); let out_md = temp.path().join("nested/summary.md"); let out_dir = temp.path().join("compare"); std::fs::write(&ours, &ours_ccr).expect("write ours"); std::fs::write(&peer, &peer_ccr).expect("write peer"); run(Args { ours_ccr: Some(ours), peer_ccr: Some(peer), out_json: Some(out.clone()), out_md: Some(out_md.clone()), out_dir: Some(out_dir.clone()), trust_anchor: "apnic".to_string(), fallback_compare_views: true, }) .expect("run"); let summary: serde_json::Value = serde_json::from_slice(&std::fs::read(out).expect("read summary")).expect("json"); assert_eq!( summary["comparePath"], "ccr_state_digest_mismatch_with_compare_views_match" ); assert_eq!(summary["allMatch"], false); assert_eq!(summary["stateDigestMatch"], false); assert_eq!(summary["mismatchedStates"], serde_json::json!(["mfts"])); assert_eq!(summary["vrps"]["ours"], 1); assert_eq!(summary["vrps"]["rpkiClient"], 1); assert_eq!(summary["vrps"]["match"], true); assert_eq!(summary["vaps"]["match"], true); assert!(out_dir.join("ours-vrps.csv").exists()); assert!( std::fs::read_to_string(out_md) .expect("read markdown") .contains("compare_views_match") ); } fn sample_content(asn: u32) -> CcrContentInfo { sample_content_with_manifest(asn, None) } fn sample_content_with_manifest_hash(asn: u32, manifest_hash_fill: u8) -> CcrContentInfo { sample_content_with_manifest( asn, Some(ManifestState { mis: Vec::new(), most_recent_update: time::OffsetDateTime::UNIX_EPOCH, hash: vec![manifest_hash_fill; 32], }), ) } fn sample_content_with_manifest(asn: u32, mfts: Option) -> CcrContentInfo { let vrps = build_roa_payload_state(&[Vrp { asn, prefix: IpPrefix { afi: RoaAfi::Ipv4, prefix_len: 24, addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, max_length: 24, }]) .expect("build vrps"); let vaps = build_aspa_payload_state(&[AspaAttestation { customer_as_id: asn, provider_as_ids: vec![64497], }]) .expect("build vaps"); CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, hash_alg: CcrDigestAlgorithm::Sha256, produced_at: time::OffsetDateTime::UNIX_EPOCH, mfts, vrps: Some(vrps), vaps: Some(vaps), tas: None, rks: None, }) } }