use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use rpki::blob_store::ExternalRepoBytesDb; use rpki::bundle::decode_ccr_compare_views; use rpki::ccr::decode_content_info; use rpki::cir::decode_cir; use rpki::data_model::roa::RoaObject; const USAGE: &str = "Usage: cir_drop_report --cir --ccr --report-json --repo-bytes-db --json-out --md-out "; #[derive(serde::Serialize)] struct DroppedObjectRecord { uri: String, sha256: String, kind: String, reason_code: String, reason_text: Option, publication_point: Option, manifest_uri: Option, derived_vrp_count: usize, } fn classify_reason(detail: Option<&str>, result: &str) -> String { let text = detail.unwrap_or("").to_ascii_lowercase(); if text.contains("fetch") { "fetch_failed".to_string() } else if text.contains("manifest") { "manifest_invalid".to_string() } else if text.contains("crl") { "crl_invalid".to_string() } else if text.contains("policy") { "policy_rejected".to_string() } else if text.contains("parse") { "object_parse_failed".to_string() } else if text.contains("signature") || text.contains("cms") { "cms_signature_invalid".to_string() } else if text.contains("resource") { "resource_invalid".to_string() } else if text.contains("expired") || text.contains("not yet valid") { "expired_or_not_yet_valid".to_string() } else if result == "skipped" { "skipped".to_string() } else if result == "error" { "error".to_string() } else { "other".to_string() } } fn parse_args( argv: &[String], ) -> Result<(PathBuf, PathBuf, PathBuf, PathBuf, PathBuf, PathBuf), String> { let mut cir = None; let mut ccr = None; let mut report = None; let mut repo_bytes_db = None; let mut json_out = None; let mut md_out = None; let mut i = 1usize; while i < argv.len() { match argv[i].as_str() { "--cir" => { i += 1; cir = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?)); } "--ccr" => { i += 1; ccr = Some(PathBuf::from(argv.get(i).ok_or("--ccr requires a value")?)); } "--report-json" => { i += 1; report = Some(PathBuf::from( argv.get(i).ok_or("--report-json requires a value")?, )); } "--repo-bytes-db" => { i += 1; repo_bytes_db = Some(PathBuf::from( argv.get(i).ok_or("--repo-bytes-db requires a value")?, )); } "--json-out" => { i += 1; json_out = Some(PathBuf::from( argv.get(i).ok_or("--json-out requires a value")?, )); } "--md-out" => { i += 1; md_out = Some(PathBuf::from( argv.get(i).ok_or("--md-out requires a value")?, )); } "-h" | "--help" => return Err(USAGE.to_string()), other => return Err(format!("unknown argument: {other}\n\n{USAGE}")), } i += 1; } Ok(( cir.ok_or_else(|| format!("--cir is required\n\n{USAGE}"))?, ccr.ok_or_else(|| format!("--ccr is required\n\n{USAGE}"))?, report.ok_or_else(|| format!("--report-json is required\n\n{USAGE}"))?, repo_bytes_db.ok_or_else(|| format!("--repo-bytes-db is required\n\n{USAGE}"))?, json_out.ok_or_else(|| format!("--json-out is required\n\n{USAGE}"))?, md_out.ok_or_else(|| format!("--md-out is required\n\n{USAGE}"))?, )) } fn main() -> Result<(), String> { let argv: Vec = std::env::args().collect(); let (cir_path, ccr_path, report_path, repo_bytes_db, json_out, md_out) = parse_args(&argv)?; let cir = decode_cir(&std::fs::read(&cir_path).map_err(|e| format!("read cir failed: {e}"))?) .map_err(|e| format!("decode cir failed: {e}"))?; let ccr = decode_content_info( &std::fs::read(&ccr_path).map_err(|e| format!("read ccr failed: {e}"))?, ) .map_err(|e| format!("decode ccr failed: {e}"))?; let (vrps, vaps) = decode_ccr_compare_views(&ccr, "unknown") .map_err(|e| format!("decode compare views failed: {e}"))?; let report: serde_json::Value = serde_json::from_slice( &std::fs::read(&report_path).map_err(|e| format!("read report failed: {e}"))?, ) .map_err(|e| format!("parse report failed: {e}"))?; let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_db) .map_err(|e| format!("open repo bytes db failed: {e}"))?; let mut object_hash_by_uri = BTreeMap::new(); for object in &cir.objects { object_hash_by_uri.insert(object.rsync_uri.clone(), hex::encode(&object.sha256)); } let publication_points = report["publication_points"] .as_array() .ok_or("report.publication_points must be an array")?; let mut dropped_objects = Vec::new(); let mut dropped_vrp_rows = BTreeSet::new(); let mut dropped_by_kind: BTreeMap = BTreeMap::new(); let mut dropped_by_reason: BTreeMap = BTreeMap::new(); let mut unknown_roa_objects = 0usize; for pp in publication_points { let publication_point = pp["publication_point_rsync_uri"] .as_str() .map(str::to_string); let manifest_uri = pp["manifest_rsync_uri"].as_str().map(str::to_string); for obj in pp["objects"].as_array().into_iter().flatten() { let result = obj["result"].as_str().unwrap_or("unknown"); if result == "ok" { continue; } let uri = obj["rsync_uri"].as_str().unwrap_or("").to_string(); let hash = obj["sha256_hex"] .as_str() .map(str::to_string) .or_else(|| object_hash_by_uri.get(&uri).cloned()) .unwrap_or_default(); let kind = obj["kind"].as_str().unwrap_or("other").to_string(); let detail = obj["detail"].as_str().map(str::to_string); let reason_code = classify_reason(detail.as_deref(), result); *dropped_by_kind.entry(kind.clone()).or_insert(0) += 1; *dropped_by_reason.entry(reason_code.clone()).or_insert(0) += 1; let mut derived_vrp_count = 0usize; if kind == "roa" && !hash.is_empty() { let bytes_opt = repo_bytes.get_blob_bytes(&hash).ok().flatten(); match bytes_opt { Some(bytes) => { if let Ok(roa) = RoaObject::decode_der(&bytes) { for family in roa.roa.ip_addr_blocks { for addr in family.addresses { let prefix = match addr.prefix.afi { rpki::data_model::roa::RoaAfi::Ipv4 => format!( "{}.{}.{}.{}/{}", addr.prefix.addr[0], addr.prefix.addr[1], addr.prefix.addr[2], addr.prefix.addr[3], addr.prefix.prefix_len ), rpki::data_model::roa::RoaAfi::Ipv6 => { let bytes: [u8; 16] = addr.prefix.addr; format!( "{}/{}", std::net::Ipv6Addr::from(bytes), addr.prefix.prefix_len ) } }; let max_len = addr.max_length.unwrap_or(addr.prefix.prefix_len); dropped_vrp_rows.insert((roa.roa.as_id, prefix, max_len)); derived_vrp_count += 1; } } } else { unknown_roa_objects += 1; } } None => unknown_roa_objects += 1, } } dropped_objects.push(DroppedObjectRecord { uri, sha256: hash, kind, reason_code, reason_text: detail, publication_point: publication_point.clone(), manifest_uri: manifest_uri.clone(), derived_vrp_count, }); } } let output = serde_json::json!({ "summary": { "finalVrpCount": vrps.len(), "finalVapCount": vaps.len(), "droppedVrpCount": dropped_vrp_rows.len(), "droppedObjectCount": dropped_objects.len(), "droppedByKind": dropped_by_kind, "droppedByReason": dropped_by_reason, "unknownDroppedRoaObjects": unknown_roa_objects, }, "objects": dropped_objects, }); if let Some(parent) = json_out.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("create json parent failed: {e}"))?; } std::fs::write(&json_out, serde_json::to_vec_pretty(&output).unwrap()) .map_err(|e| format!("write json failed: {e}"))?; let mut md = String::new(); md.push_str("# CIR Drop Report\n\n"); md.push_str(&format!("- `final_vrp_count`: `{}`\n", vrps.len())); md.push_str(&format!("- `final_vap_count`: `{}`\n", vaps.len())); md.push_str(&format!( "- `dropped_vrp_count`: `{}`\n", output["summary"]["droppedVrpCount"] )); md.push_str(&format!( "- `dropped_object_count`: `{}`\n", output["summary"]["droppedObjectCount"] )); md.push_str(&format!( "- `unknown_dropped_roa_objects`: `{}`\n\n", output["summary"]["unknownDroppedRoaObjects"] )); md.push_str("## Dropped By Kind\n\n"); for (kind, count) in output["summary"]["droppedByKind"] .as_object() .into_iter() .flatten() { md.push_str(&format!("- `{kind}`: `{}`\n", count.as_u64().unwrap_or(0))); } md.push_str("\n## Dropped By Reason\n\n"); for (reason, count) in output["summary"]["droppedByReason"] .as_object() .into_iter() .flatten() { md.push_str(&format!( "- `{reason}`: `{}`\n", count.as_u64().unwrap_or(0) )); } if let Some(parent) = md_out.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("create markdown parent failed: {e}"))?; } std::fs::write(&md_out, md).map_err(|e| format!("write markdown failed: {e}"))?; Ok(()) }