use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use rpki::cir::decode_cir; use serde_json::json; use sha2::{Digest, Sha256}; #[derive(Debug, Default, PartialEq, Eq)] struct Args { ours_cir: Option, peer_cir: Option, cache_root: Option, rpki_client_log: Option, out_json: Option, sample_limit: usize, } fn usage() -> &'static str { "Usage: cir_probe_rpki_client_cache --ours-cir --rpki-client-cir --cache-root --out-json [--rpki-client-log ] [--sample-limit ]" } fn main() { if let Err(err) = real_main() { eprintln!("{err}"); std::process::exit(1); } } fn real_main() -> Result<(), String> { run(parse_args(&std::env::args().collect::>())?) } fn parse_args(argv: &[String]) -> Result { let mut args = Args { sample_limit: 20, ..Args::default() }; let mut index = 1usize; while index < argv.len() { match argv[index].as_str() { "--ours-cir" => { index += 1; args.ours_cir = Some(PathBuf::from( argv.get(index).ok_or("--ours-cir requires a value")?, )); } "--rpki-client-cir" | "--peer-cir" => { index += 1; args.peer_cir = Some(PathBuf::from( argv.get(index) .ok_or("--rpki-client-cir requires a value")?, )); } "--cache-root" => { index += 1; args.cache_root = Some(PathBuf::from( argv.get(index).ok_or("--cache-root requires a value")?, )); } "--rpki-client-log" => { index += 1; args.rpki_client_log = Some(PathBuf::from( argv.get(index) .ok_or("--rpki-client-log requires a value")?, )); } "--out-json" => { index += 1; args.out_json = Some(PathBuf::from( argv.get(index).ok_or("--out-json requires a value")?, )); } "--sample-limit" => { index += 1; let value = argv.get(index).ok_or("--sample-limit requires a value")?; args.sample_limit = value .parse::() .map_err(|_| format!("invalid --sample-limit: {value}"))?; } "-h" | "--help" => return Err(usage().to_string()), other => return Err(format!("unknown argument: {other}\n{}", usage())), } index += 1; } if args.ours_cir.is_none() { return Err(format!("--ours-cir is required\n{}", usage())); } if args.peer_cir.is_none() { return Err(format!("--rpki-client-cir is required\n{}", usage())); } if args.cache_root.is_none() { return Err(format!("--cache-root is required\n{}", usage())); } if args.out_json.is_none() { return Err(format!("--out-json is required\n{}", usage())); } Ok(args) } fn run(args: Args) -> Result<(), String> { let ours_cir_path = args.ours_cir.as_ref().expect("validated"); let peer_cir_path = args.peer_cir.as_ref().expect("validated"); let cache_root = args.cache_root.as_ref().expect("validated"); let out_json = args.out_json.as_ref().expect("validated"); let ours = decode_cir(&read_file(ours_cir_path)?) .map_err(|e| format!("decode ours CIR failed: {e}"))?; let peer = decode_cir(&read_file(peer_cir_path)?) .map_err(|e| format!("decode rpki-client CIR failed: {e}"))?; let peer_objects = peer .validated_objects() .map(|item| item.rsync_uri.as_str()) .collect::>(); let only_in_ours = ours .validated_objects() .filter(|item| !peer_objects.contains(item.rsync_uri.as_str())) .map(|item| ProbeObject { uri: item.rsync_uri.clone(), sha256_hex: hex::encode(&item.sha256), }) .collect::>(); let rrdp_dirs = list_rrdp_dirs(cache_root)?; let log_mentions = match args.rpki_client_log.as_ref() { Some(path) => build_log_mentions(path, &only_in_ours)?, None => LogMentions::default(), }; let mut cache_hash_match_count = 0usize; let mut cache_hash_mismatch_count = 0usize; let mut cache_missing_count = 0usize; let mut valid_path_exists_count = 0usize; let mut rrdp_temp_path_exists_count = 0usize; let mut rsync_temp_path_exists_count = 0usize; let mut log_mention_count = 0usize; let mut samples = Vec::new(); let mut missing_uris = Vec::new(); let mut matched_uris = Vec::new(); let mut mismatched_uris = Vec::new(); let mut log_mentioned_uris = Vec::new(); for object in &only_in_ours { let probe = probe_cache(cache_root, &rrdp_dirs, object)?; if probe.hash_match { cache_hash_match_count += 1; matched_uris.push(object.uri.as_str()); } else if probe.exists { cache_hash_mismatch_count += 1; mismatched_uris.push(object.uri.as_str()); } else { cache_missing_count += 1; missing_uris.push(object.uri.as_str()); } if probe.valid_path_exists { valid_path_exists_count += 1; } if probe.rrdp_temp_path_exists { rrdp_temp_path_exists_count += 1; } if probe.rsync_temp_path_exists { rsync_temp_path_exists_count += 1; } let log_mention = log_mentions.mentioned.contains(object.uri.as_str()); if log_mention { log_mention_count += 1; log_mentioned_uris.push(object.uri.as_str()); } if samples.len() < args.sample_limit { samples.push(json!({ "uri": object.uri, "sha256": object.sha256_hex, "cacheStatus": probe.status(), "cacheLocations": probe.locations, "logMentionedAsSuperfluousOrDeleted": log_mention, })); } } let summary = json!({ "onlyInOursTotal": only_in_ours.len(), "cacheRoot": cache_root, "rpkiClientLog": args.rpki_client_log, "rrdpCacheDirs": rrdp_dirs.len(), "cacheProbe": { "hashMatchCount": cache_hash_match_count, "hashMismatchCount": cache_hash_mismatch_count, "missingCount": cache_missing_count, "validPathExistsCount": valid_path_exists_count, "rrdpTempPathExistsCount": rrdp_temp_path_exists_count, "rsyncTempPathExistsCount": rsync_temp_path_exists_count, "hashMatchByExtension": group_by_extension(matched_uris.iter().copied()), "missingByExtension": group_by_extension(missing_uris.iter().copied()), "hashMismatchByExtension": group_by_extension(mismatched_uris.iter().copied()), "hashMatchByHostTop": top_hosts(matched_uris.iter().copied(), args.sample_limit), "missingByHostTop": top_hosts(missing_uris.iter().copied(), args.sample_limit), "hashMismatchByHostTop": top_hosts(mismatched_uris.iter().copied(), args.sample_limit), }, "logProbe": { "enabled": args.rpki_client_log.is_some(), "relevantLineCount": log_mentions.relevant_line_count, "mentionedCount": log_mention_count, "mentionedByExtension": group_by_extension(log_mentioned_uris.iter().copied()), "mentionedByHostTop": top_hosts(log_mentioned_uris.iter().copied(), args.sample_limit), }, "samples": samples, }); write_json(out_json, &summary)?; println!("{}", out_json.display()); Ok(()) } #[derive(Clone, Debug)] struct ProbeObject { uri: String, sha256_hex: String, } #[derive(Default, Debug)] struct CacheProbe { exists: bool, hash_match: bool, valid_path_exists: bool, rrdp_temp_path_exists: bool, rsync_temp_path_exists: bool, locations: Vec, } impl CacheProbe { fn status(&self) -> &'static str { if self.hash_match { "hash_match" } else if self.exists { "hash_mismatch" } else { "missing" } } } fn probe_cache( cache_root: &Path, rrdp_dirs: &[PathBuf], object: &ProbeObject, ) -> Result { let Some(stripped) = strip_rsync_uri(&object.uri) else { return Ok(CacheProbe::default()); }; let mut probe = CacheProbe::default(); let mut candidates = vec![ ("valid", cache_root.join(stripped)), ("rsync_temp", cache_root.join(".rsync").join(stripped)), ]; for dir in rrdp_dirs { candidates.push(("rrdp_temp", dir.join(stripped))); } for (kind, path) in candidates { if !path.is_file() { continue; } let sha256_hex = sha256_file_hex(&path)?; let hash_matches = sha256_hex == object.sha256_hex; probe.exists = true; probe.hash_match |= hash_matches; match kind { "valid" => probe.valid_path_exists = true, "rsync_temp" => probe.rsync_temp_path_exists = true, "rrdp_temp" => probe.rrdp_temp_path_exists = true, _ => {} } if probe.locations.len() < 4 { probe.locations.push(json!({ "kind": kind, "path": path, "sha256": sha256_hex, "hashMatches": hash_matches, })); } } Ok(probe) } #[derive(Default, Debug)] struct LogMentions { mentioned: BTreeSet, relevant_line_count: usize, } fn build_log_mentions(path: &Path, objects: &[ProbeObject]) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("read log failed: {}: {e}", path.display()))?; let mut by_file_name: HashMap> = HashMap::new(); for object in objects { let Some(stripped) = strip_rsync_uri(&object.uri) else { continue; }; by_file_name .entry(file_name(stripped).to_string()) .or_default() .push((object.uri.as_str(), stripped)); } let mut mentioned = BTreeSet::new(); let mut relevant_line_count = 0usize; for line in content.lines() { if !is_relevant_cleanup_line(line) { continue; } relevant_line_count += 1; let Some(name) = line.split('/').next_back().map(trim_log_token) else { continue; }; let Some(candidates) = by_file_name.get(name) else { continue; }; for (uri, stripped) in candidates { if line.contains(stripped) { mentioned.insert((*uri).to_string()); } } } Ok(LogMentions { mentioned, relevant_line_count, }) } fn is_relevant_cleanup_line(line: &str) -> bool { line.contains("superfluous") || line.contains("deleted ") || line.contains("deleted superfluous") || line.contains("bad message digest") || line.contains("referenced file supposed to be deleted") } fn trim_log_token(token: &str) -> &str { token.trim_matches(|ch: char| ch == ':' || ch == ',' || ch == ')' || ch == '(') } fn list_rrdp_dirs(cache_root: &Path) -> Result, String> { let rrdp = cache_root.join(".rrdp"); let mut dirs = Vec::new(); let Ok(read_dir) = std::fs::read_dir(&rrdp) else { return Ok(dirs); }; for entry in read_dir { let entry = entry.map_err(|e| format!("read .rrdp entry failed: {e}"))?; let path = entry.path(); if path.is_dir() { dirs.push(path); } } dirs.sort(); Ok(dirs) } fn sha256_file_hex(path: &Path) -> Result { let bytes = std::fs::read(path) .map_err(|e| format!("read cache file failed: {}: {e}", path.display()))?; Ok(hex::encode(Sha256::digest(&bytes))) } 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 strip_rsync_uri(uri: &str) -> Option<&str> { uri.strip_prefix("rsync://") } fn file_name(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } fn group_by_extension<'a>(uris: impl IntoIterator) -> BTreeMap { let mut counts = BTreeMap::new(); for uri in uris { *counts.entry(uri_extension(uri)).or_insert(0) += 1; } counts } fn top_hosts<'a>(uris: impl IntoIterator, limit: usize) -> Vec { let mut counts = BTreeMap::new(); for uri in uris { *counts.entry(uri_host(uri)).or_insert(0usize) += 1; } let mut rows = counts.into_iter().collect::>(); rows.sort_by(|(host_a, count_a), (host_b, count_b)| { count_b.cmp(count_a).then_with(|| host_a.cmp(host_b)) }); rows.into_iter() .take(limit) .map(|(host, count)| { json!({ "host": host, "count": count, }) }) .collect() } fn uri_host(uri: &str) -> String { let without_scheme = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri); without_scheme .split('/') .next() .filter(|host| !host.is_empty()) .unwrap_or("") .to_string() } fn uri_extension(uri: &str) -> String { let path = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri); let path = path.split_once('/').map(|(_, path)| path).unwrap_or(path); let file = path.rsplit('/').next().unwrap_or(path); if let Some((_, ext)) = file.rsplit_once('.') && !ext.is_empty() { return format!(".{}", ext.to_ascii_lowercase()); } "".to_string() } #[cfg(test)] mod tests { use super::*; use rpki::cir::{CanonicalInputRepresentation, CirObject, CirTrustAnchor, encode_cir}; #[test] fn parse_args_accepts_required_flags() { let args = parse_args(&[ "cir_probe_rpki_client_cache".to_string(), "--ours-cir".to_string(), "ours.cir".to_string(), "--rpki-client-cir".to_string(), "peer.cir".to_string(), "--cache-root".to_string(), "cache".to_string(), "--out-json".to_string(), "probe.json".to_string(), "--sample-limit".to_string(), "7".to_string(), ]) .expect("parse args"); assert_eq!(args.ours_cir.as_deref(), Some(Path::new("ours.cir"))); assert_eq!(args.peer_cir.as_deref(), Some(Path::new("peer.cir"))); assert_eq!(args.cache_root.as_deref(), Some(Path::new("cache"))); assert_eq!(args.out_json.as_deref(), Some(Path::new("probe.json"))); assert_eq!(args.sample_limit, 7); } #[test] fn parse_args_rejects_missing_invalid_and_unknown_flags() { let missing = parse_args(&[ "cir_probe_rpki_client_cache".to_string(), "--ours-cir".to_string(), "ours.cir".to_string(), "--peer-cir".to_string(), "peer.cir".to_string(), "--cache-root".to_string(), "cache".to_string(), ]) .unwrap_err(); assert!(missing.contains("--out-json is required"), "{missing}"); let invalid_limit = parse_args(&[ "cir_probe_rpki_client_cache".to_string(), "--ours-cir".to_string(), "ours.cir".to_string(), "--peer-cir".to_string(), "peer.cir".to_string(), "--cache-root".to_string(), "cache".to_string(), "--out-json".to_string(), "probe.json".to_string(), "--sample-limit".to_string(), "NaN".to_string(), ]) .unwrap_err(); assert!( invalid_limit.contains("invalid --sample-limit"), "{invalid_limit}" ); let unknown = parse_args(&[ "cir_probe_rpki_client_cache".to_string(), "--unexpected".to_string(), ]) .unwrap_err(); assert!(unknown.contains("unknown argument"), "{unknown}"); } #[test] fn run_probes_valid_temp_rrdp_missing_and_log_mentions() { let temp = tempfile::tempdir().expect("tempdir"); let cache_root = temp.path().join("cache"); let valid_bytes = b"valid object bytes"; let rrdp_bytes = b"rrdp object bytes"; let expected_mismatch_bytes = b"expected mismatch bytes"; write_cache_file( &cache_root.join("rpki.example.test/repo/a.roa"), valid_bytes, ); write_cache_file( &cache_root.join(".rsync/rpki.example.test/repo/b.mft"), b"different bytes", ); write_cache_file( &cache_root.join(".rrdp/session-1/rpki.example.test/repo/c.cer"), rrdp_bytes, ); let ours_cir = sample_cir(&[ ( "rsync://missing.example.test/repo/e.crl", b"missing bytes".as_slice(), ), ( "rsync://rpki.example.test/repo/a.roa", valid_bytes.as_slice(), ), ( "rsync://rpki.example.test/repo/b.mft", expected_mismatch_bytes.as_slice(), ), ( "rsync://rpki.example.test/repo/c.cer", rrdp_bytes.as_slice(), ), ( "rsync://shared.example.test/repo/d.roa", b"shared".as_slice(), ), ]); let peer_cir = sample_cir(&[( "rsync://shared.example.test/repo/d.roa", b"shared".as_slice(), )]); let ours = temp.path().join("ours.cir"); let peer = temp.path().join("peer.cir"); write_cir(&ours, &ours_cir); write_cir(&peer, &peer_cir); let log = temp.path().join("rpki-client.log"); std::fs::write( &log, "rpki-client: deleted superfluous /cache/rpki.example.test/repo/b.mft\n", ) .expect("write log"); let out_json = temp.path().join("nested/probe.json"); run(Args { ours_cir: Some(ours), peer_cir: Some(peer), cache_root: Some(cache_root), rpki_client_log: Some(log), out_json: Some(out_json.clone()), sample_limit: 10, }) .expect("run"); let summary: serde_json::Value = serde_json::from_slice(&std::fs::read(out_json).expect("read summary")) .expect("summary json"); assert_eq!(summary["onlyInOursTotal"], 4); assert_eq!(summary["rrdpCacheDirs"], 1); assert_eq!(summary["cacheProbe"]["hashMatchCount"], 2); assert_eq!(summary["cacheProbe"]["hashMismatchCount"], 1); assert_eq!(summary["cacheProbe"]["missingCount"], 1); assert_eq!(summary["cacheProbe"]["validPathExistsCount"], 1); assert_eq!(summary["cacheProbe"]["rsyncTempPathExistsCount"], 1); assert_eq!(summary["cacheProbe"]["rrdpTempPathExistsCount"], 1); assert_eq!(summary["logProbe"]["enabled"], true); assert_eq!(summary["logProbe"]["relevantLineCount"], 1); assert_eq!(summary["logProbe"]["mentionedCount"], 1); assert_eq!(summary["samples"].as_array().unwrap().len(), 4); } #[test] fn run_reports_decode_failure_with_side_label() { let temp = tempfile::tempdir().expect("tempdir"); let ours = temp.path().join("ours.cir"); let peer = temp.path().join("peer.cir"); std::fs::write(&ours, b"not a cir").expect("write invalid"); write_cir( &peer, &sample_cir(&[( "rsync://shared.example.test/repo/d.roa", b"shared".as_slice(), )]), ); let err = run(Args { ours_cir: Some(ours), peer_cir: Some(peer), cache_root: Some(temp.path().join("cache")), rpki_client_log: None, out_json: Some(temp.path().join("probe.json")), sample_limit: 20, }) .unwrap_err(); assert!(err.contains("decode ours CIR failed"), "{err}"); } #[test] fn uri_helpers_extract_rsync_path_host_and_extension() { let uri = "rsync://rpki.example.test/repo/a/b.ROA"; assert_eq!(strip_rsync_uri(uri), Some("rpki.example.test/repo/a/b.ROA")); assert_eq!(uri_host(uri), "rpki.example.test"); assert_eq!(uri_extension(uri), ".roa"); assert_eq!(file_name("rpki.example.test/repo/a/b.ROA"), "b.ROA"); } #[test] fn log_mentions_match_cleanup_lines_by_uri_suffix() { let temp = tempfile::tempdir().expect("tempdir"); let log = temp.path().join("run.log"); std::fs::write( &log, "rpki-client: deleted superfluous .rrdp/abc/rpki.example.test/repo/a.roa\n", ) .expect("write log"); let objects = vec![ProbeObject { uri: "rsync://rpki.example.test/repo/a.roa".to_string(), sha256_hex: "00".repeat(32), }]; let mentions = build_log_mentions(&log, &objects).expect("mentions"); assert_eq!(mentions.relevant_line_count, 1); assert!( mentions .mentioned .contains("rsync://rpki.example.test/repo/a.roa") ); } fn sample_cir(objects: &[(&str, &[u8])]) -> CanonicalInputRepresentation { let mut objects = objects .iter() .map(|(rsync_uri, bytes)| CirObject { rsync_uri: (*rsync_uri).to_string(), sha256: Sha256::digest(bytes).to_vec(), }) .collect::>(); objects.sort_by(|left, right| left.rsync_uri.cmp(&right.rsync_uri)); CanonicalInputRepresentation::new_v4( time::OffsetDateTime::UNIX_EPOCH, objects, Vec::new(), vec![sample_trust_anchor()], Vec::new(), Vec::new(), ) } fn sample_trust_anchor() -> CirTrustAnchor { let ta_rsync_uri = "rsync://example.test/ta.cer"; let ta_certificate_der = b"ta-der".to_vec(); CirTrustAnchor { ta_rsync_uri: ta_rsync_uri.to_string(), tal_uri: "https://tal.example.test/apnic.tal".to_string(), tal_bytes: format!("{ta_rsync_uri}\n\nAQID\n").into_bytes(), ta_certificate_sha256: Sha256::digest(&ta_certificate_der).to_vec(), ta_certificate_der, } } fn write_cir(path: &Path, cir: &CanonicalInputRepresentation) { std::fs::write(path, encode_cir(cir).expect("encode cir")).expect("write cir"); } fn write_cache_file(path: &Path, bytes: &[u8]) { std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); std::fs::write(path, bytes).expect("write cache file"); } }