1use crate::graph::{AuthorityCompleteness, AuthorityGraph, GapKind, NodeId, NodeKind, TrustZone};
8use crate::propagation::{propagation_analysis_checked, DenseGraphError, PropagationPath};
9use serde::Serialize;
10use std::collections::HashMap;
11
12pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION: &str = "1.1.0";
20
21pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI: &str =
23 "https://taudit.dev/schemas/authority-propagation-summary.v1.json";
24
25pub const PROPAGATION_SUMMARY_TOP_N: usize = 32;
27
28#[derive(Debug, Clone, Serialize)]
30pub struct PropagationNodeAgg {
31 pub node_id: NodeId,
32 pub kind: NodeKind,
33 pub name: String,
34 pub trust_zone: TrustZone,
35 pub path_count: usize,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct PropagationSummaryTotals {
41 pub boundary_path_count: usize,
43 pub distinct_authority_sources: usize,
44 pub distinct_sinks: usize,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct AuthorityPropagationSummaryDocument {
50 pub schema_version: &'static str,
51 pub schema_uri: &'static str,
52 pub source_file: String,
53 pub graph_completeness: AuthorityCompleteness,
54 #[serde(skip_serializing_if = "Vec::is_empty")]
55 pub completeness_gaps: Vec<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub completeness_gap_kinds: Vec<GapKind>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub worst_gap_kind: Option<GapKind>,
67 pub max_hops: usize,
68 pub method: &'static str,
69 pub totals: PropagationSummaryTotals,
70 pub top_sinks_by_path_count: Vec<PropagationNodeAgg>,
71 pub top_sources_by_path_count: Vec<PropagationNodeAgg>,
72}
73
74fn rank_node_aggs(
75 counts: HashMap<NodeId, usize>,
76 graph: &AuthorityGraph,
77 top_n: usize,
78) -> Vec<PropagationNodeAgg> {
79 let mut pairs: Vec<(NodeId, usize)> = counts.into_iter().collect();
80 pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
81 pairs
82 .into_iter()
83 .take(top_n)
84 .filter_map(|(id, path_count)| {
85 let n = graph.node(id)?;
86 Some(PropagationNodeAgg {
87 node_id: id,
88 kind: n.kind,
89 name: n.name.clone(),
90 trust_zone: n.trust_zone,
91 path_count,
92 })
93 })
94 .collect()
95}
96
97pub fn build_authority_propagation_summary(
100 graph: &AuthorityGraph,
101 max_hops: usize,
102 force_dense: bool,
103) -> Result<AuthorityPropagationSummaryDocument, DenseGraphError> {
104 let paths: Vec<PropagationPath> = propagation_analysis_checked(graph, max_hops, force_dense)?;
105
106 let mut sink_count: HashMap<NodeId, usize> = HashMap::new();
107 let mut source_count: HashMap<NodeId, usize> = HashMap::new();
108 for p in &paths {
109 *sink_count.entry(p.sink).or_insert(0) += 1;
110 *source_count.entry(p.source).or_insert(0) += 1;
111 }
112
113 Ok(AuthorityPropagationSummaryDocument {
114 schema_version: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION,
115 schema_uri: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI,
116 source_file: graph.source.file.clone(),
117 graph_completeness: graph.completeness,
118 completeness_gaps: graph.completeness_gaps.clone(),
119 completeness_gap_kinds: graph.completeness_gap_kinds.clone(),
120 worst_gap_kind: graph.worst_gap_kind(),
121 max_hops,
122 method: "bfs_lower_trust_zone_sinks",
123 totals: PropagationSummaryTotals {
124 boundary_path_count: paths.len(),
125 distinct_authority_sources: source_count.len(),
126 distinct_sinks: sink_count.len(),
127 },
128 top_sinks_by_path_count: rank_node_aggs(sink_count, graph, PROPAGATION_SUMMARY_TOP_N),
129 top_sources_by_path_count: rank_node_aggs(source_count, graph, PROPAGATION_SUMMARY_TOP_N),
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::graph::{EdgeKind, PipelineSource};
137
138 fn src(file: &str) -> PipelineSource {
139 PipelineSource {
140 file: file.into(),
141 repo: None,
142 git_ref: None,
143 commit_sha: None,
144 }
145 }
146
147 #[test]
148 fn summary_counts_crossing_paths() {
149 let mut g = AuthorityGraph::new(src("t.yml"));
150 let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
151 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
152 let art = g.add_node(NodeKind::Artifact, "a", TrustZone::FirstParty);
153 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
154 g.add_edge(build, secret, EdgeKind::HasAccessTo);
155 g.add_edge(build, art, EdgeKind::Produces);
156 g.add_edge(art, deploy, EdgeKind::Consumes);
157
158 let doc =
159 build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
160 .unwrap();
161 assert_eq!(doc.totals.boundary_path_count, 1);
162 assert_eq!(doc.totals.distinct_authority_sources, 1);
163 assert_eq!(doc.totals.distinct_sinks, 1);
164 assert_eq!(doc.top_sinks_by_path_count.len(), 1);
165 assert_eq!(doc.top_sinks_by_path_count[0].node_id, deploy);
166 assert_eq!(doc.top_sources_by_path_count[0].node_id, secret);
167 }
168
169 #[test]
170 fn summary_carries_gap_kinds_from_graph() {
171 let mut g = AuthorityGraph::new(src("partial.yml"));
175 g.mark_partial(GapKind::Structural, "composite action not resolved");
176 g.mark_partial(GapKind::Expression, "matrix expansion hides paths");
177 g.mark_partial(GapKind::Opaque, "platform unknown");
178
179 let doc =
180 build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
181 .unwrap();
182
183 assert_eq!(doc.graph_completeness, AuthorityCompleteness::Partial);
184 assert_eq!(doc.completeness_gaps.len(), 3);
185 assert_eq!(
186 doc.completeness_gap_kinds,
187 vec![GapKind::Structural, GapKind::Expression, GapKind::Opaque,]
188 );
189 assert_eq!(
191 doc.completeness_gap_kinds.len(),
192 doc.completeness_gaps.len(),
193 "completeness_gap_kinds must be a parallel array of completeness_gaps"
194 );
195 assert_eq!(doc.worst_gap_kind, Some(GapKind::Opaque));
198 }
199
200 #[test]
201 fn summary_omits_gap_kinds_when_complete() {
202 let g = AuthorityGraph::new(src("clean.yml"));
206
207 let doc =
208 build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
209 .unwrap();
210 assert!(doc.completeness_gap_kinds.is_empty());
211 assert_eq!(doc.worst_gap_kind, None);
212
213 let v = serde_json::to_value(&doc).expect("summary doc serialises");
214 assert!(
215 v.get("completeness_gap_kinds").is_none(),
216 "empty completeness_gap_kinds must be skipped on the wire"
217 );
218 assert!(
219 v.get("worst_gap_kind").is_none(),
220 "absent worst_gap_kind must be skipped on the wire"
221 );
222 assert_eq!(
223 v.get("schema_version").and_then(|x| x.as_str()),
224 Some("1.1.0"),
225 "schema_version must reflect the additive 1.1.0 bump"
226 );
227 }
228
229 #[test]
230 fn summary_empty_when_no_crossing() {
231 let mut g = AuthorityGraph::new(src("t.yml"));
232 let secret = g.add_node(NodeKind::Secret, "T", TrustZone::FirstParty);
233 let step = g.add_node(NodeKind::Step, "s", TrustZone::FirstParty);
234 g.add_edge(step, secret, EdgeKind::HasAccessTo);
235
236 let doc =
237 build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
238 .unwrap();
239 assert_eq!(doc.totals.boundary_path_count, 0);
240 assert!(doc.top_sinks_by_path_count.is_empty());
241 }
242}