1use crate::graph::{
2 AuthorityGraph, EdgeKind, NodeId, NodeKind, TrustZone, META_IDENTITY_SCOPE, META_JOB_NAME,
3};
4use std::collections::{HashSet, VecDeque};
5
6#[derive(Debug)]
8pub struct MapRow {
9 pub step_name: String,
10 pub trust_zone: String,
11 pub access: Vec<bool>,
13}
14
15#[derive(Debug)]
17pub struct AuthorityMap {
18 pub authorities: Vec<String>,
20 pub rows: Vec<MapRow>,
22}
23
24pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
26 struct RawAuthority {
29 id: NodeId,
30 name: String,
31 zone: String,
32 scope: Option<String>,
33 }
34
35 let raw: Vec<RawAuthority> = graph
36 .authority_sources()
37 .map(|n| RawAuthority {
38 id: n.id,
39 name: n.name.clone(),
40 zone: format!("{:?}", n.trust_zone),
41 scope: n.metadata.get(META_IDENTITY_SCOPE).cloned(),
42 })
43 .collect();
44
45 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
47 for r in &raw {
48 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
49 }
50
51 let mut qualifier_counts: std::collections::HashMap<(String, String), usize> =
55 std::collections::HashMap::new();
56 for r in &raw {
57 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
58 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
59 *qualifier_counts
60 .entry((r.name.clone(), qualifier))
61 .or_insert(0) += 1;
62 }
63 }
64
65 let mut seen: std::collections::HashMap<(String, String), usize> =
66 std::collections::HashMap::new();
67 let authority_names: Vec<String> = raw
68 .iter()
69 .map(|r| {
70 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) <= 1 {
71 r.name.clone()
73 } else {
74 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
75 let key = (r.name.clone(), qualifier.clone());
76 let total_with_qualifier = qualifier_counts.get(&key).copied().unwrap_or(1);
77 let idx = {
78 let entry = seen.entry(key).or_insert(0);
79 *entry += 1;
80 *entry
81 };
82 if total_with_qualifier <= 1 {
83 format!("{} ({})", r.name, qualifier)
85 } else {
86 format!("{} ({}#{})", r.name, qualifier, idx)
88 }
89 }
90 })
91 .collect();
92
93 let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
96
97 let mut rows = Vec::new();
99 for step in graph.nodes_of_kind(NodeKind::Step) {
100 let mut access = vec![false; authorities.len()];
101
102 for edge in graph.edges_from(step.id) {
103 if edge.kind != EdgeKind::HasAccessTo {
104 continue;
105 }
106 if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
108 access[idx] = true;
109 }
110 }
111
112 rows.push(MapRow {
113 step_name: step.name.clone(),
114 trust_zone: format!("{:?}", step.trust_zone),
115 access,
116 });
117 }
118
119 AuthorityMap {
120 authorities: authority_names,
121 rows,
122 }
123}
124
125fn zone_abbr(zone: &str) -> &'static str {
128 match zone {
129 "FirstParty" => "1P",
130 "ThirdParty" => "3P",
131 _ => "?",
132 }
133}
134
135fn trunc(s: &str, max: usize) -> String {
137 let n = s.chars().count();
138 if n <= max {
139 s.to_string()
140 } else {
141 let mut out: String = s.chars().take(max - 1).collect();
142 out.push('…');
143 out
144 }
145}
146
147pub fn render_map(map: &AuthorityMap, term_width: usize) -> String {
154 if map.rows.is_empty() && map.authorities.is_empty() {
155 return "No steps or authority sources found.\n".to_string();
156 }
157
158 const MAX_STEP: usize = 28;
160 const MAX_COL: usize = 18;
161 const ZONE_W: usize = 4; let step_width = map
164 .rows
165 .iter()
166 .map(|r| r.step_name.chars().count().min(MAX_STEP))
167 .max()
168 .unwrap_or(4)
169 .max(4);
170
171 let prefix_width = step_width + 2 + ZONE_W + 2;
173
174 let display_names: Vec<String> = map.authorities.iter().map(|a| trunc(a, MAX_COL)).collect();
176 let any_truncated = display_names
177 .iter()
178 .zip(map.authorities.iter())
179 .any(|(d, o)| d != o);
180
181 let auth_widths: Vec<usize> = display_names
183 .iter()
184 .map(|a| a.chars().count().max(3))
185 .collect();
186
187 let total_cols = auth_widths.len();
191 let mut groups: Vec<(usize, usize)> = Vec::new(); let mut gi = 0;
193 while gi < total_cols {
194 let mut used = prefix_width;
195 let mut end = gi;
196 while end < total_cols {
197 let next = used + auth_widths[end] + 2;
198 if next > term_width && end > gi {
199 break;
200 }
201 used = next;
202 end += 1;
203 }
204 groups.push((gi, end));
205 gi = end;
206 }
207
208 let multi_group = groups.len() > 1;
209 let mut out = String::new();
210
211 for (group_idx, &(start, end)) in groups.iter().enumerate() {
212 if multi_group {
213 out.push_str(&format!(
214 " columns {}-{} of {}\n",
215 start + 1,
216 end,
217 total_cols
218 ));
219 }
220
221 out.push_str(&format!(
223 "{:<step_w$} {:<zone_w$}",
224 "Step",
225 "Zone",
226 step_w = step_width,
227 zone_w = ZONE_W,
228 ));
229 for (name, w) in display_names[start..end]
230 .iter()
231 .zip(&auth_widths[start..end])
232 {
233 out.push_str(&format!(" {name:^w$}"));
234 }
235 out.push('\n');
236
237 out.push_str(&"-".repeat(step_width));
239 out.push_str(" ");
240 out.push_str(&"-".repeat(ZONE_W));
241 for w in &auth_widths[start..end] {
242 out.push_str(" ");
243 out.push_str(&"-".repeat(*w));
244 }
245 out.push('\n');
246
247 for row in &map.rows {
249 let step_display = trunc(&row.step_name, MAX_STEP);
250 let zone_display = zone_abbr(&row.trust_zone);
251 out.push_str(&format!(
252 "{step_display:<step_width$} {zone_display:<ZONE_W$}"
253 ));
254 for (col, w) in auth_widths[start..end].iter().enumerate() {
255 let marker = if row.access[start + col] { "✓" } else { "·" };
256 out.push_str(&format!(" {marker:^w$}"));
257 }
258 out.push('\n');
259 }
260
261 if group_idx + 1 < groups.len() {
262 out.push('\n');
263 }
264 }
265
266 if any_truncated {
267 out.push_str(&format!(
268 "\nnote: column names truncated to {MAX_COL} chars\n"
269 ));
270 }
271
272 out
273}
274
275fn dot_shape(kind: NodeKind) -> &'static str {
279 match kind {
280 NodeKind::Step => "ellipse",
281 NodeKind::Secret => "box",
282 NodeKind::Identity => "diamond",
283 NodeKind::Artifact => "hexagon",
284 NodeKind::Image => "cylinder",
285 }
286}
287
288fn dot_color(zone: TrustZone) -> &'static str {
290 match zone {
291 TrustZone::FirstParty => "green",
292 TrustZone::ThirdParty => "yellow",
293 TrustZone::Untrusted => "red",
294 }
295}
296
297fn edge_label(kind: EdgeKind) -> &'static str {
300 match kind {
301 EdgeKind::HasAccessTo => "has_access_to",
302 EdgeKind::Produces => "produces",
303 EdgeKind::Consumes => "consumes",
304 EdgeKind::UsesImage => "uses_image",
305 EdgeKind::DelegatesTo => "delegates_to",
306 EdgeKind::PersistsTo => "persists_to",
307 }
308}
309
310fn dot_escape(s: &str) -> String {
313 let mut out = String::with_capacity(s.len());
314 for c in s.chars() {
315 match c {
316 '\\' => out.push_str("\\\\"),
317 '"' => out.push_str("\\\""),
318 '\n' => out.push_str("\\n"),
319 _ => out.push(c),
320 }
321 }
322 out
323}
324
325fn reachable_set(graph: &AuthorityGraph, seeds: &[NodeId]) -> HashSet<NodeId> {
331 let mut visited: HashSet<NodeId> = HashSet::new();
332 let mut queue: VecDeque<NodeId> = VecDeque::new();
333 for &s in seeds {
334 if visited.insert(s) {
335 queue.push_back(s);
336 }
337 }
338 while let Some(n) = queue.pop_front() {
339 for e in &graph.edges {
340 let next = if e.from == n {
341 Some(e.to)
342 } else if e.to == n {
343 Some(e.from)
344 } else {
345 None
346 };
347 if let Some(nx) = next {
348 if visited.insert(nx) {
349 queue.push_back(nx);
350 }
351 }
352 }
353 }
354 visited
355}
356
357pub fn render_dot(graph: &AuthorityGraph, filter_job: Option<&str>) -> String {
367 let included: Option<HashSet<NodeId>> = match filter_job {
368 Some(name) => {
369 let seeds: Vec<NodeId> = graph
370 .nodes
371 .iter()
372 .filter(|n| {
373 n.kind == NodeKind::Step
374 && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
375 })
376 .map(|n| n.id)
377 .collect();
378 Some(reachable_set(graph, &seeds))
379 }
380 None => None,
381 };
382
383 let mut out = String::new();
384 out.push_str("digraph taudit {\n");
385 out.push_str(" rankdir=LR;\n");
386 out.push_str(" node [fontname=\"Helvetica\"];\n");
387
388 for node in &graph.nodes {
389 if let Some(ref keep) = included {
390 if !keep.contains(&node.id) {
391 continue;
392 }
393 }
394 out.push_str(&format!(
395 " \"n{}\" [label=\"{}\" shape={} color={}];\n",
396 node.id,
397 dot_escape(&node.name),
398 dot_shape(node.kind),
399 dot_color(node.trust_zone),
400 ));
401 }
402
403 for edge in &graph.edges {
404 if let Some(ref keep) = included {
405 if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
406 continue;
407 }
408 }
409 out.push_str(&format!(
410 " \"n{}\" -> \"n{}\" [label=\"{}\"];\n",
411 edge.from,
412 edge.to,
413 edge_label(edge.kind),
414 ));
415 }
416
417 out.push_str("}\n");
418 out
419}
420
421pub fn job_names(graph: &AuthorityGraph) -> Vec<String> {
425 let mut names: Vec<String> = graph
426 .nodes
427 .iter()
428 .filter(|n| n.kind == NodeKind::Step)
429 .filter_map(|n| n.metadata.get(META_JOB_NAME).cloned())
430 .collect::<HashSet<_>>()
431 .into_iter()
432 .collect();
433 names.sort();
434 names
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::graph::*;
441
442 fn source(file: &str) -> PipelineSource {
443 PipelineSource {
444 file: file.into(),
445 repo: None,
446 git_ref: None,
447 commit_sha: None,
448 }
449 }
450
451 #[test]
452 fn map_shows_step_access() {
453 let mut g = AuthorityGraph::new(source("ci.yml"));
454 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
455 let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
456 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
457 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
458
459 g.add_edge(build, secret, EdgeKind::HasAccessTo);
460 g.add_edge(build, token, EdgeKind::HasAccessTo);
461 g.add_edge(deploy, token, EdgeKind::HasAccessTo);
462
463 let map = authority_map(&g);
464 assert_eq!(map.authorities.len(), 2);
465 assert_eq!(map.rows.len(), 2);
466
467 let build_row = &map.rows[0];
469 assert!(build_row.access[0]); assert!(build_row.access[1]); let deploy_row = &map.rows[1];
474 assert!(!deploy_row.access[0]); assert!(deploy_row.access[1]); }
477
478 #[test]
479 fn map_renders_table() {
480 let mut g = AuthorityGraph::new(source("ci.yml"));
481 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
482 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
483 g.add_edge(step, secret, EdgeKind::HasAccessTo);
484
485 let map = authority_map(&g);
486 let table = render_map(&map, 120);
487 assert!(table.contains("build"));
488 assert!(table.contains("KEY"));
489 assert!(table.contains('✓'));
490 }
491
492 #[test]
493 fn dot_output_contains_expected_node_and_edge() {
494 let mut g = AuthorityGraph::new(source("ci.yml"));
495 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
496 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
497 g.add_edge(step, secret, EdgeKind::HasAccessTo);
498
499 let dot = render_dot(&g, None);
500 assert!(dot.starts_with("digraph taudit"), "dot output: {dot}");
501 assert!(
503 dot.contains(&format!("\"n{step}\" [label=\"build\" shape=ellipse")),
504 "missing step node line in: {dot}"
505 );
506 assert!(
507 dot.contains(&format!("\"n{secret}\" [label=\"API_KEY\" shape=box")),
508 "missing secret node line in: {dot}"
509 );
510 assert!(
512 dot.contains(&format!(
513 "\"n{step}\" -> \"n{secret}\" [label=\"has_access_to\"]"
514 )),
515 "missing edge line in: {dot}"
516 );
517 }
518
519 #[test]
520 fn job_filter_produces_subset_of_full_map() {
521 let mut g = AuthorityGraph::new(source("ci.yml"));
525
526 let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
527 let mut build_meta = std::collections::HashMap::new();
528 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
529 let build_step =
530 g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
531 g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
532
533 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
534 let mut deploy_meta = std::collections::HashMap::new();
535 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
536 let deploy_step =
537 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
538 g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
539
540 let full = render_dot(&g, None);
541 let filtered = render_dot(&g, Some("build"));
542
543 assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
545 assert!(filtered.contains("BUILD_SECRET"));
546 assert!(
547 !filtered.contains("DEPLOY_SECRET"),
548 "deploy-job nodes leaked into build filter: {filtered}"
549 );
550 assert!(!filtered.contains("\"ship\""));
551
552 let full_lines = full.lines().count();
554 let filtered_lines = filtered.lines().count();
555 assert!(
556 filtered_lines < full_lines,
557 "filtered DOT ({filtered_lines} lines) not smaller than full ({full_lines})"
558 );
559 }
560
561 #[test]
562 fn job_names_lists_distinct_jobs_sorted() {
563 let mut g = AuthorityGraph::new(source("ci.yml"));
564 let mut a_meta = std::collections::HashMap::new();
565 a_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
566 g.add_node_with_metadata(NodeKind::Step, "s1", TrustZone::FirstParty, a_meta);
567 let mut b_meta = std::collections::HashMap::new();
568 b_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
569 g.add_node_with_metadata(NodeKind::Step, "s2", TrustZone::FirstParty, b_meta);
570 let mut c_meta = std::collections::HashMap::new();
571 c_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
572 g.add_node_with_metadata(NodeKind::Step, "s3", TrustZone::FirstParty, c_meta);
573
574 let names = job_names(&g);
575 assert_eq!(names, vec!["build".to_string(), "deploy".to_string()]);
576 }
577}