use crate::graph::{AuthorityCompleteness, AuthorityGraph, GapKind, NodeId, NodeKind, TrustZone};
use crate::propagation::{propagation_analysis_checked, DenseGraphError, PropagationPath};
use serde::Serialize;
use std::collections::HashMap;
pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION: &str = "1.1.0";
pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI: &str =
"https://taudit.dev/schemas/authority-propagation-summary.v1.json";
pub const PROPAGATION_SUMMARY_TOP_N: usize = 32;
#[derive(Debug, Clone, Serialize)]
pub struct PropagationNodeAgg {
pub node_id: NodeId,
pub kind: NodeKind,
pub name: String,
pub trust_zone: TrustZone,
pub path_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PropagationSummaryTotals {
pub boundary_path_count: usize,
pub distinct_authority_sources: usize,
pub distinct_sinks: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuthorityPropagationSummaryDocument {
pub schema_version: &'static str,
pub schema_uri: &'static str,
pub source_file: String,
pub graph_completeness: AuthorityCompleteness,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub completeness_gaps: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub completeness_gap_kinds: Vec<GapKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worst_gap_kind: Option<GapKind>,
pub max_hops: usize,
pub method: &'static str,
pub totals: PropagationSummaryTotals,
pub top_sinks_by_path_count: Vec<PropagationNodeAgg>,
pub top_sources_by_path_count: Vec<PropagationNodeAgg>,
}
fn rank_node_aggs(
counts: HashMap<NodeId, usize>,
graph: &AuthorityGraph,
top_n: usize,
) -> Vec<PropagationNodeAgg> {
let mut pairs: Vec<(NodeId, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
pairs
.into_iter()
.take(top_n)
.filter_map(|(id, path_count)| {
let n = graph.node(id)?;
Some(PropagationNodeAgg {
node_id: id,
kind: n.kind,
name: n.name.clone(),
trust_zone: n.trust_zone,
path_count,
})
})
.collect()
}
pub fn build_authority_propagation_summary(
graph: &AuthorityGraph,
max_hops: usize,
force_dense: bool,
) -> Result<AuthorityPropagationSummaryDocument, DenseGraphError> {
let paths: Vec<PropagationPath> = propagation_analysis_checked(graph, max_hops, force_dense)?;
let mut sink_count: HashMap<NodeId, usize> = HashMap::new();
let mut source_count: HashMap<NodeId, usize> = HashMap::new();
for p in &paths {
*sink_count.entry(p.sink).or_insert(0) += 1;
*source_count.entry(p.source).or_insert(0) += 1;
}
Ok(AuthorityPropagationSummaryDocument {
schema_version: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION,
schema_uri: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI,
source_file: graph.source.file.clone(),
graph_completeness: graph.completeness,
completeness_gaps: graph.completeness_gaps.clone(),
completeness_gap_kinds: graph.completeness_gap_kinds.clone(),
worst_gap_kind: graph.worst_gap_kind(),
max_hops,
method: "bfs_lower_trust_zone_sinks",
totals: PropagationSummaryTotals {
boundary_path_count: paths.len(),
distinct_authority_sources: source_count.len(),
distinct_sinks: sink_count.len(),
},
top_sinks_by_path_count: rank_node_aggs(sink_count, graph, PROPAGATION_SUMMARY_TOP_N),
top_sources_by_path_count: rank_node_aggs(source_count, graph, PROPAGATION_SUMMARY_TOP_N),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{EdgeKind, PipelineSource};
fn src(file: &str) -> PipelineSource {
PipelineSource {
file: file.into(),
repo: None,
git_ref: None,
commit_sha: None,
}
}
#[test]
fn summary_counts_crossing_paths() {
let mut g = AuthorityGraph::new(src("t.yml"));
let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
let art = g.add_node(NodeKind::Artifact, "a", TrustZone::FirstParty);
let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
g.add_edge(build, secret, EdgeKind::HasAccessTo);
g.add_edge(build, art, EdgeKind::Produces);
g.add_edge(art, deploy, EdgeKind::Consumes);
let doc =
build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
.unwrap();
assert_eq!(doc.totals.boundary_path_count, 1);
assert_eq!(doc.totals.distinct_authority_sources, 1);
assert_eq!(doc.totals.distinct_sinks, 1);
assert_eq!(doc.top_sinks_by_path_count.len(), 1);
assert_eq!(doc.top_sinks_by_path_count[0].node_id, deploy);
assert_eq!(doc.top_sources_by_path_count[0].node_id, secret);
}
#[test]
fn summary_carries_gap_kinds_from_graph() {
let mut g = AuthorityGraph::new(src("partial.yml"));
g.mark_partial(GapKind::Structural, "composite action not resolved");
g.mark_partial(GapKind::Expression, "matrix expansion hides paths");
g.mark_partial(GapKind::Opaque, "platform unknown");
let doc =
build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
.unwrap();
assert_eq!(doc.graph_completeness, AuthorityCompleteness::Partial);
assert_eq!(doc.completeness_gaps.len(), 3);
assert_eq!(
doc.completeness_gap_kinds,
vec![GapKind::Structural, GapKind::Expression, GapKind::Opaque,]
);
assert_eq!(
doc.completeness_gap_kinds.len(),
doc.completeness_gaps.len(),
"completeness_gap_kinds must be a parallel array of completeness_gaps"
);
assert_eq!(doc.worst_gap_kind, Some(GapKind::Opaque));
}
#[test]
fn summary_omits_gap_kinds_when_complete() {
let g = AuthorityGraph::new(src("clean.yml"));
let doc =
build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
.unwrap();
assert!(doc.completeness_gap_kinds.is_empty());
assert_eq!(doc.worst_gap_kind, None);
let v = serde_json::to_value(&doc).expect("summary doc serialises");
assert!(
v.get("completeness_gap_kinds").is_none(),
"empty completeness_gap_kinds must be skipped on the wire"
);
assert!(
v.get("worst_gap_kind").is_none(),
"absent worst_gap_kind must be skipped on the wire"
);
assert_eq!(
v.get("schema_version").and_then(|x| x.as_str()),
Some("1.1.0"),
"schema_version must reflect the additive 1.1.0 bump"
);
}
#[test]
fn summary_empty_when_no_crossing() {
let mut g = AuthorityGraph::new(src("t.yml"));
let secret = g.add_node(NodeKind::Secret, "T", TrustZone::FirstParty);
let step = g.add_node(NodeKind::Step, "s", TrustZone::FirstParty);
g.add_edge(step, secret, EdgeKind::HasAccessTo);
let doc =
build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
.unwrap();
assert_eq!(doc.totals.boundary_path_count, 0);
assert!(doc.top_sinks_by_path_count.is_empty());
}
}