use std::io::BufWriter; use std::path::Path; use serde::Serialize; use serde::ser::SerializeSeq; use sha2::Digest; use crate::audit::{ AspaOutput, AuditRunMeta, AuditWarning, QueryAuditManifest, ValidationEvent, ValidationEventCounts, VrpOutput, }; use crate::ccr::canonical_vrp_prefix; use super::{PostValidationShared, RunStageTiming}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum ReportJsonFormat { Pretty, Compact, } pub(super) fn write_json( path: &Path, report: &T, format: ReportJsonFormat, ) -> Result<(), String> { let f = std::fs::File::create(path) .map_err(|e| format!("create report file failed: {}: {e}", path.display()))?; let writer = BufWriter::new(f); match format { ReportJsonFormat::Pretty => serde_json::to_writer_pretty(writer, report), ReportJsonFormat::Compact => serde_json::to_writer(writer, report), } .map_err(|e| format!("write report json failed: {e}"))?; Ok(()) } #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct ReportJsonWriteTiming { pub(super) build_ms: u64, pub(super) write_ms: u64, } #[derive(Serialize)] struct BorrowedAuditReportV2<'a> { format_version: u32, meta: AuditRunMeta, policy: &'a crate::policy::Policy, tree: BorrowedTreeSummary<'a>, publication_points: &'a [crate::audit::PublicationPointAudit], vrps: VrpReportSequence<'a>, aspas: AspaReportSequence<'a>, downloads: &'a [crate::audit::AuditDownloadEvent], download_stats: &'a crate::audit::AuditDownloadStats, repo_sync_stats: crate::audit::AuditRepoSyncStats, #[serde(rename = "queryAudit", skip_serializing_if = "Option::is_none")] query_audit: Option, } #[derive(Serialize)] struct BorrowedTreeSummary<'a> { instances_processed: usize, instances_failed: usize, warnings: WarningReportSequence<'a>, } struct WarningReportSequence<'a>(&'a [crate::report::Warning]); impl Serialize for WarningReportSequence<'_> { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { let mut seq = serializer.serialize_seq(Some(self.0.len()))?; for warning in self.0 { seq.serialize_element(&AuditWarning::from(warning))?; } seq.end() } } struct VrpReportSequence<'a>(&'a [crate::validation::objects::Vrp]); impl Serialize for VrpReportSequence<'_> { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { let mut seq = serializer.serialize_seq(Some(self.0.len()))?; for vrp in self.0 { seq.serialize_element(&VrpOutput { asn: vrp.asn, prefix: crate::audit::format_roa_ip_prefix(&vrp.prefix), max_length: vrp.max_length, })?; } seq.end() } } struct AspaReportSequence<'a>(&'a [crate::validation::objects::AspaAttestation]); impl Serialize for AspaReportSequence<'_> { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { let mut seq = serializer.serialize_seq(Some(self.0.len()))?; for aspa in self.0 { seq.serialize_element(&AspaOutput { customer_as_id: aspa.customer_as_id, provider_as_ids: aspa.provider_as_ids.clone(), })?; } seq.end() } } pub(super) fn write_report_json_from_shared( path: &Path, policy: &crate::policy::Policy, validation_time: time::OffsetDateTime, shared: &PostValidationShared, format: ReportJsonFormat, ) -> Result { use time::format_description::well_known::Rfc3339; let build_started = std::time::Instant::now(); let validation_time_rfc3339_utc = validation_time .to_offset(time::UtcOffset::UTC) .format(&Rfc3339) .expect("format validation_time"); let repo_sync_stats = super::build_repo_sync_stats(shared.publication_points.as_ref()); let query_audit = write_validation_events_sidecar(path, &validation_time_rfc3339_utc, shared)?; let report = BorrowedAuditReportV2 { format_version: 2, meta: AuditRunMeta { validation_time_rfc3339_utc, }, policy, tree: BorrowedTreeSummary { instances_processed: shared.instances_processed, instances_failed: shared.instances_failed, warnings: WarningReportSequence(shared.tree_warnings.as_ref()), }, publication_points: shared.publication_points.as_ref(), vrps: VrpReportSequence(shared.vrps.as_ref()), aspas: AspaReportSequence(shared.aspas.as_ref()), downloads: shared.downloads.as_ref(), download_stats: &shared.download_stats, repo_sync_stats, query_audit: Some(query_audit), }; let build_ms = build_started.elapsed().as_millis() as u64; let write_started = std::time::Instant::now(); write_json(path, &report, format)?; Ok(ReportJsonWriteTiming { build_ms, write_ms: write_started.elapsed().as_millis() as u64, }) } fn write_validation_events_sidecar( report_path: &Path, validation_time: &str, shared: &PostValidationShared, ) -> Result { let events_path = report_path.with_file_name("validation-events.jsonl"); if let Some(parent) = events_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("create validation events parent failed: {e}"))?; } let mut writer = BufWriter::new(std::fs::File::create(&events_path).map_err(|e| { format!( "create validation events failed: {}: {e}", events_path.display() ) })?); let mut seq = 0u64; let mut hasher = sha2::Sha256::new(); emit_validation_events(validation_time, shared, &mut seq, &mut |event| { let mut line = serde_json::to_vec(&event) .map_err(|e| format!("serialize validation event failed: {e}"))?; line.push(b'\n'); std::io::Write::write_all(&mut writer, &line) .map_err(|e| format!("write validation event failed: {e}"))?; hasher.update(&line); Ok(()) })?; std::io::Write::flush(&mut writer) .map_err(|e| format!("flush validation events failed: {e}"))?; let events_count = seq; let events_sha256 = hex::encode(hasher.finalize()); Ok(QueryAuditManifest { schema_version: 1, status: "complete".to_string(), events_path: events_path .file_name() .and_then(|name| name.to_str()) .unwrap_or("validation-events.jsonl") .to_string(), events_count, events_sha256, writer_version: 1, error: None, }) } fn emit_validation_events( validation_time: &str, shared: &PostValidationShared, seq: &mut u64, emit: &mut impl FnMut(ValidationEvent) -> Result<(), String>, ) -> Result<(), String> { emit(next_event(seq, "run_summary", validation_time, |event| { event.counts = Some(ValidationEventCounts { objects: Some( shared .publication_points .iter() .map(|pp| pp.objects.len() as u64) .sum(), ), warnings: Some( (shared.tree_warnings.len() + shared .publication_points .iter() .map(|pp| pp.warnings.len()) .sum::()) as u64, ), vrps: Some(shared.vrps.len() as u64), aspas: Some(shared.aspas.len() as u64), }); }))?; for pp in shared.publication_points.iter() { emit(next_event( seq, "publication_point", validation_time, |event| { event.pp_node_id = pp.node_id; event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone()); event.pp_rsync_base_uri = Some(pp.rsync_base_uri.clone()); event.repo_sync_phase = pp.repo_sync_phase.clone(); event.repo_terminal_state = Some(pp.repo_terminal_state.clone()); event.counts = Some(ValidationEventCounts { objects: Some(pp.objects.len() as u64), warnings: Some(pp.warnings.len() as u64), vrps: None, aspas: None, }); }, ))?; for object in &pp.objects { emit(next_event(seq, "object", validation_time, |event| { event.pp_node_id = pp.node_id; event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone()); event.object_uri = Some(object.rsync_uri.clone()); event.sha256 = Some(object.sha256_hex.clone()); event.object_type = Some(object.kind.clone()); event.result = Some(object.result.clone()); event.reason = object.detail.clone(); }))?; } for warning in &pp.warnings { emit(next_event(seq, "warning", validation_time, |event| { event.pp_node_id = pp.node_id; event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone()); event.reason = Some(warning.message.clone()); }))?; } } Ok(()) } fn next_event( seq: &mut u64, event_type: &str, validation_time: &str, fill: impl FnOnce(&mut ValidationEvent), ) -> ValidationEvent { *seq += 1; let mut event = ValidationEvent { schema_version: 1, seq: *seq, event_type: event_type.to_string(), validation_time: validation_time.to_string(), pp_node_id: None, pp_manifest_uri: None, pp_rsync_base_uri: None, repo_sync_phase: None, repo_terminal_state: None, object_uri: None, sha256: None, object_type: None, result: None, reason: None, counts: None, }; fill(&mut event); event } #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct CompareViewTaskOutput { pub(super) build_ms: Option, pub(super) write_ms: Option, } pub(super) fn run_compare_view_task( shared: &PostValidationShared, vrps_csv_out_path: Option<&Path>, vaps_csv_out_path: Option<&Path>, trust_anchor: &str, ) -> Result { let mut build_ms = None; let mut write_ms = None; if let (Some(vrps_path), Some(vaps_path)) = (vrps_csv_out_path, vaps_csv_out_path) { let started = std::time::Instant::now(); build_ms = Some(0); write_direct_vrp_csv(vrps_path, shared.vrps.as_ref(), trust_anchor)?; write_direct_vap_csv(vaps_path, shared.aspas.as_ref(), trust_anchor)?; write_ms = Some(started.elapsed().as_millis() as u64); eprintln!( "wrote compare views: vrps={} vaps={}", vrps_path.display(), vaps_path.display() ); } Ok(CompareViewTaskOutput { build_ms, write_ms }) } fn write_direct_vrp_csv( path: &Path, vrps: &[crate::validation::objects::Vrp], trust_anchor: &str, ) -> 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 file = std::fs::File::create(path) .map_err(|e| format!("create file failed: {}: {e}", path.display()))?; let mut writer = BufWriter::new(file); use std::io::Write; let trust_anchor = trust_anchor.to_ascii_lowercase(); writeln!(writer, "ASN,IP Prefix,Max Length,Trust Anchor").map_err(|e| e.to_string())?; for vrp in vrps { writeln!( writer, "AS{},{},{},{}", vrp.asn, canonical_vrp_prefix(&vrp.prefix), vrp.max_length, trust_anchor ) .map_err(|e| e.to_string())?; } Ok(()) } fn write_direct_vap_csv( path: &Path, aspas: &[crate::validation::objects::AspaAttestation], trust_anchor: &str, ) -> 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 file = std::fs::File::create(path) .map_err(|e| format!("create file failed: {}: {e}", path.display()))?; let mut writer = BufWriter::new(file); use std::io::Write; let trust_anchor = trust_anchor.to_ascii_lowercase(); writeln!(writer, "Customer ASN,Providers,Trust Anchor").map_err(|e| e.to_string())?; for aspa in aspas { let mut providers = aspa.provider_as_ids.clone(); providers.sort_unstable(); providers.dedup(); let providers = providers .into_iter() .map(|asn| format!("AS{asn}")) .collect::>() .join(";"); writeln!( writer, "AS{},{},{}", aspa.customer_as_id, providers, trust_anchor ) .map_err(|e| e.to_string())?; } Ok(()) } pub(super) fn write_stage_timing( report_json_path: Option<&Path>, stage_timing: &RunStageTiming, ) -> Result<(), String> { if let Some(path) = report_json_path && let Some(parent) = path.parent() { let stage_timing_path = parent.join("stage-timing.json"); std::fs::write( &stage_timing_path, serde_json::to_vec_pretty(stage_timing).map_err(|e| e.to_string())?, ) .map_err(|e| { format!( "write stage timing failed: {}: {e}", stage_timing_path.display() ) })?; eprintln!("analysis: wrote {}", stage_timing_path.display()); } Ok(()) }