1use crate::graph::{
2 AuthorityCompleteness, AuthorityGraph, EdgeKind, NodeId, NodeKind, TrustZone,
3 META_IDENTITY_SCOPE, META_JOB_NAME,
4};
5use std::collections::{HashSet, VecDeque};
6
7#[derive(Debug)]
9pub struct MapRow {
10 pub step_name: String,
11 pub trust_zone: String,
12 pub access: Vec<bool>,
14}
15
16#[derive(Debug)]
18pub struct AuthorityMap {
19 pub authorities: Vec<String>,
21 pub rows: Vec<MapRow>,
23}
24
25pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
27 struct RawAuthority {
30 id: NodeId,
31 name: String,
32 zone: String,
33 scope: Option<String>,
34 }
35
36 let raw: Vec<RawAuthority> = graph
37 .authority_sources()
38 .map(|n| RawAuthority {
39 id: n.id,
40 name: n.name.clone(),
41 zone: format!("{:?}", n.trust_zone),
42 scope: n.metadata.get(META_IDENTITY_SCOPE).cloned(),
43 })
44 .collect();
45
46 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
48 for r in &raw {
49 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
50 }
51
52 let mut qualifier_counts: std::collections::HashMap<(String, String), usize> =
56 std::collections::HashMap::new();
57 for r in &raw {
58 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
59 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
60 *qualifier_counts
61 .entry((r.name.clone(), qualifier))
62 .or_insert(0) += 1;
63 }
64 }
65
66 let mut seen: std::collections::HashMap<(String, String), usize> =
67 std::collections::HashMap::new();
68 let authority_names: Vec<String> = raw
69 .iter()
70 .map(|r| {
71 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) <= 1 {
72 r.name.clone()
74 } else {
75 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
76 let key = (r.name.clone(), qualifier.clone());
77 let total_with_qualifier = qualifier_counts.get(&key).copied().unwrap_or(1);
78 let idx = {
79 let entry = seen.entry(key).or_insert(0);
80 *entry += 1;
81 *entry
82 };
83 if total_with_qualifier <= 1 {
84 format!("{} ({})", r.name, qualifier)
86 } else {
87 format!("{} ({}#{})", r.name, qualifier, idx)
89 }
90 }
91 })
92 .collect();
93
94 let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
97
98 let mut rows = Vec::new();
100 for step in graph.nodes_of_kind(NodeKind::Step) {
101 let mut access = vec![false; authorities.len()];
102
103 for edge in graph.edges_from(step.id) {
104 if edge.kind != EdgeKind::HasAccessTo {
105 continue;
106 }
107 if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
109 access[idx] = true;
110 }
111 }
112
113 rows.push(MapRow {
114 step_name: step.name.clone(),
115 trust_zone: format!("{:?}", step.trust_zone),
116 access,
117 });
118 }
119
120 AuthorityMap {
121 authorities: authority_names,
122 rows,
123 }
124}
125
126fn zone_abbr(zone: &str) -> &'static str {
129 match zone {
130 "FirstParty" => "1P",
131 "ThirdParty" => "3P",
132 _ => "?",
133 }
134}
135
136fn trunc(s: &str, max: usize) -> String {
138 let n = s.chars().count();
139 if n <= max {
140 s.to_string()
141 } else {
142 let mut out: String = s.chars().take(max - 1).collect();
143 out.push('…');
144 out
145 }
146}
147
148pub fn render_map(map: &AuthorityMap, term_width: usize) -> String {
155 if map.rows.is_empty() && map.authorities.is_empty() {
156 return "No steps or authority sources found.\n".to_string();
157 }
158
159 const MAX_STEP: usize = 28;
161 const MAX_COL: usize = 18;
162 const ZONE_W: usize = 4; let step_width = map
165 .rows
166 .iter()
167 .map(|r| r.step_name.chars().count().min(MAX_STEP))
168 .max()
169 .unwrap_or(4)
170 .max(4);
171
172 let prefix_width = step_width + 2 + ZONE_W + 2;
174
175 let display_names: Vec<String> = map.authorities.iter().map(|a| trunc(a, MAX_COL)).collect();
177 let any_truncated = display_names
178 .iter()
179 .zip(map.authorities.iter())
180 .any(|(d, o)| d != o);
181
182 let auth_widths: Vec<usize> = display_names
184 .iter()
185 .map(|a| a.chars().count().max(3))
186 .collect();
187
188 let total_cols = auth_widths.len();
192 let mut groups: Vec<(usize, usize)> = Vec::new(); let mut gi = 0;
194 while gi < total_cols {
195 let mut used = prefix_width;
196 let mut end = gi;
197 while end < total_cols {
198 let next = used + auth_widths[end] + 2;
199 if next > term_width && end > gi {
200 break;
201 }
202 used = next;
203 end += 1;
204 }
205 groups.push((gi, end));
206 gi = end;
207 }
208
209 let multi_group = groups.len() > 1;
210 let mut out = String::new();
211
212 for (group_idx, &(start, end)) in groups.iter().enumerate() {
213 if multi_group {
214 out.push_str(&format!(
215 " columns {}-{} of {}\n",
216 start + 1,
217 end,
218 total_cols
219 ));
220 }
221
222 out.push_str(&format!(
224 "{:<step_w$} {:<zone_w$}",
225 "Step",
226 "Zone",
227 step_w = step_width,
228 zone_w = ZONE_W,
229 ));
230 for (name, w) in display_names[start..end]
231 .iter()
232 .zip(&auth_widths[start..end])
233 {
234 out.push_str(&format!(" {name:^w$}"));
235 }
236 out.push('\n');
237
238 out.push_str(&"-".repeat(step_width));
240 out.push_str(" ");
241 out.push_str(&"-".repeat(ZONE_W));
242 for w in &auth_widths[start..end] {
243 out.push_str(" ");
244 out.push_str(&"-".repeat(*w));
245 }
246 out.push('\n');
247
248 for row in &map.rows {
250 let step_display = trunc(&row.step_name, MAX_STEP);
251 let zone_display = zone_abbr(&row.trust_zone);
252 out.push_str(&format!(
253 "{step_display:<step_width$} {zone_display:<ZONE_W$}"
254 ));
255 for (col, w) in auth_widths[start..end].iter().enumerate() {
256 let marker = if row.access[start + col] { "✓" } else { "·" };
257 out.push_str(&format!(" {marker:^w$}"));
258 }
259 out.push('\n');
260 }
261
262 if group_idx + 1 < groups.len() {
263 out.push('\n');
264 }
265 }
266
267 if any_truncated {
268 out.push_str(&format!(
269 "\nnote: column names truncated to {MAX_COL} chars\n"
270 ));
271 }
272
273 out
274}
275
276fn dot_shape(kind: NodeKind) -> &'static str {
280 match kind {
281 NodeKind::Step => "ellipse",
282 NodeKind::Secret => "box",
283 NodeKind::Identity => "diamond",
284 NodeKind::Artifact => "hexagon",
285 NodeKind::Image => "cylinder",
286 }
287}
288
289fn dot_color(zone: TrustZone) -> &'static str {
291 match zone {
292 TrustZone::FirstParty => "green",
293 TrustZone::ThirdParty => "yellow",
294 TrustZone::Untrusted => "red",
295 }
296}
297
298fn edge_label(kind: EdgeKind) -> &'static str {
301 match kind {
302 EdgeKind::HasAccessTo => "has_access_to",
303 EdgeKind::Produces => "produces",
304 EdgeKind::Consumes => "consumes",
305 EdgeKind::UsesImage => "uses_image",
306 EdgeKind::DelegatesTo => "delegates_to",
307 EdgeKind::PersistsTo => "persists_to",
308 }
309}
310
311fn dot_escape(s: &str) -> String {
314 let mut out = String::with_capacity(s.len());
315 for c in s.chars() {
316 match c {
317 '\\' => out.push_str("\\\\"),
318 '"' => out.push_str("\\\""),
319 '\n' => out.push_str("\\n"),
320 _ => out.push(c),
321 }
322 }
323 out
324}
325
326fn reachable_set(graph: &AuthorityGraph, seeds: &[NodeId]) -> HashSet<NodeId> {
332 let mut visited: HashSet<NodeId> = HashSet::new();
333 let mut queue: VecDeque<NodeId> = VecDeque::new();
334 for &s in seeds {
335 if visited.insert(s) {
336 queue.push_back(s);
337 }
338 }
339 while let Some(n) = queue.pop_front() {
340 for e in &graph.edges {
341 let next = if e.from == n {
342 Some(e.to)
343 } else if e.to == n {
344 Some(e.from)
345 } else {
346 None
347 };
348 if let Some(nx) = next {
349 if visited.insert(nx) {
350 queue.push_back(nx);
351 }
352 }
353 }
354 }
355 visited
356}
357
358pub fn render_dot(graph: &AuthorityGraph, filter_job: Option<&str>) -> String {
368 let included: Option<HashSet<NodeId>> = match filter_job {
369 Some(name) => {
370 let seeds: Vec<NodeId> = graph
371 .nodes
372 .iter()
373 .filter(|n| {
374 n.kind == NodeKind::Step
375 && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
376 })
377 .map(|n| n.id)
378 .collect();
379 Some(reachable_set(graph, &seeds))
380 }
381 None => None,
382 };
383
384 let mut out = String::new();
385 out.push_str("digraph taudit {\n");
386 out.push_str(" rankdir=LR;\n");
387 out.push_str(" node [fontname=\"Helvetica\"];\n");
388
389 for node in &graph.nodes {
390 if let Some(ref keep) = included {
391 if !keep.contains(&node.id) {
392 continue;
393 }
394 }
395 out.push_str(&format!(
396 " \"n{}\" [label=\"{}\" shape={} color={}];\n",
397 node.id,
398 dot_escape(&node.name),
399 dot_shape(node.kind),
400 dot_color(node.trust_zone),
401 ));
402 }
403
404 for edge in &graph.edges {
405 if let Some(ref keep) = included {
406 if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
407 continue;
408 }
409 }
410 out.push_str(&format!(
411 " \"n{}\" -> \"n{}\" [label=\"{}\"];\n",
412 edge.from,
413 edge.to,
414 edge_label(edge.kind),
415 ));
416 }
417
418 out.push_str("}\n");
419 out
420}
421
422fn mermaid_label_escape(s: &str) -> String {
426 let mut out = String::with_capacity(s.len() + 8);
427 for c in s.chars() {
428 match c {
429 '&' => out.push_str("&"),
430 '<' => out.push_str("<"),
431 '>' => out.push_str(">"),
432 '"' => out.push_str("""),
433 '\n' | '\r' => out.push(' '),
434 '|' => out.push_str("|"),
436 '[' => out.push_str("["),
437 ']' => out.push_str("]"),
438 '{' | '}' => out.push('·'),
439 _ => out.push(c),
440 }
441 }
442 out
443}
444
445fn mermaid_node_line(node: &crate::graph::Node) -> String {
448 let id = node.id;
449 let esc = mermaid_label_escape(&node.name);
450 match node.kind {
451 NodeKind::Step => format!(r#" n{id}("{esc}")"#),
452 NodeKind::Secret => format!(r#" n{id}["{esc}"]"#),
453 NodeKind::Identity => format!(r#" n{id}{{"{esc}"}}"#),
454 NodeKind::Artifact => format!(r#" n{id}[["{esc}"]]"#),
455 NodeKind::Image => format!(r#" n{id}[("{esc}")]"#),
456 }
457}
458
459pub fn render_mermaid(graph: &AuthorityGraph, filter_job: Option<&str>) -> String {
466 let included: Option<HashSet<NodeId>> = match filter_job {
467 Some(name) => {
468 let seeds: Vec<NodeId> = graph
469 .nodes
470 .iter()
471 .filter(|n| {
472 n.kind == NodeKind::Step
473 && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
474 })
475 .map(|n| n.id)
476 .collect();
477 Some(reachable_set(graph, &seeds))
478 }
479 None => None,
480 };
481
482 let mut out = String::new();
483 if graph.completeness != AuthorityCompleteness::Complete {
484 out.push_str(
485 "%% taudit: authority graph is not Complete; use JSON for completeness and gaps\n",
486 );
487 }
488 out.push_str("flowchart LR\n");
489
490 for node in &graph.nodes {
491 if let Some(ref keep) = included {
492 if !keep.contains(&node.id) {
493 continue;
494 }
495 }
496 out.push_str(&mermaid_node_line(node));
497 out.push('\n');
498 }
499
500 for edge in &graph.edges {
501 if let Some(ref keep) = included {
502 if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
503 continue;
504 }
505 }
506 let el = mermaid_label_escape(edge_label(edge.kind));
507 out.push_str(&format!(" n{} -->|{}| n{}\n", edge.from, el, edge.to));
508 }
509
510 out
511}
512
513pub fn job_names(graph: &AuthorityGraph) -> Vec<String> {
517 let mut names: Vec<String> = graph
518 .nodes
519 .iter()
520 .filter(|n| n.kind == NodeKind::Step)
521 .filter_map(|n| n.metadata.get(META_JOB_NAME).cloned())
522 .collect::<HashSet<_>>()
523 .into_iter()
524 .collect();
525 names.sort();
526 names
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::graph::*;
533
534 fn source(file: &str) -> PipelineSource {
535 PipelineSource {
536 file: file.into(),
537 repo: None,
538 git_ref: None,
539 commit_sha: None,
540 }
541 }
542
543 #[test]
544 fn map_shows_step_access() {
545 let mut g = AuthorityGraph::new(source("ci.yml"));
546 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
547 let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
548 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
549 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
550
551 g.add_edge(build, secret, EdgeKind::HasAccessTo);
552 g.add_edge(build, token, EdgeKind::HasAccessTo);
553 g.add_edge(deploy, token, EdgeKind::HasAccessTo);
554
555 let map = authority_map(&g);
556 assert_eq!(map.authorities.len(), 2);
557 assert_eq!(map.rows.len(), 2);
558
559 let build_row = &map.rows[0];
561 assert!(build_row.access[0]); assert!(build_row.access[1]); let deploy_row = &map.rows[1];
566 assert!(!deploy_row.access[0]); assert!(deploy_row.access[1]); }
569
570 #[test]
571 fn map_renders_table() {
572 let mut g = AuthorityGraph::new(source("ci.yml"));
573 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
574 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
575 g.add_edge(step, secret, EdgeKind::HasAccessTo);
576
577 let map = authority_map(&g);
578 let table = render_map(&map, 120);
579 assert!(table.contains("build"));
580 assert!(table.contains("KEY"));
581 assert!(table.contains('✓'));
582 }
583
584 #[test]
585 fn dot_output_contains_expected_node_and_edge() {
586 let mut g = AuthorityGraph::new(source("ci.yml"));
587 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
588 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
589 g.add_edge(step, secret, EdgeKind::HasAccessTo);
590
591 let dot = render_dot(&g, None);
592 assert!(dot.starts_with("digraph taudit"), "dot output: {dot}");
593 assert!(
595 dot.contains(&format!("\"n{step}\" [label=\"build\" shape=ellipse")),
596 "missing step node line in: {dot}"
597 );
598 assert!(
599 dot.contains(&format!("\"n{secret}\" [label=\"API_KEY\" shape=box")),
600 "missing secret node line in: {dot}"
601 );
602 assert!(
604 dot.contains(&format!(
605 "\"n{step}\" -> \"n{secret}\" [label=\"has_access_to\"]"
606 )),
607 "missing edge line in: {dot}"
608 );
609 }
610
611 #[test]
612 fn mermaid_output_contains_expected_node_and_edge() {
613 let mut g = AuthorityGraph::new(source("ci.yml"));
614 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
615 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
616 g.add_edge(step, secret, EdgeKind::HasAccessTo);
617
618 let mer = render_mermaid(&g, None);
619 assert!(mer.starts_with("flowchart LR"), "mermaid output: {mer}");
620 assert!(
621 mer.contains(&format!(r#"n{}("build")"#, step)),
622 "missing step node line in: {mer}"
623 );
624 assert!(
625 mer.contains(&format!(r#"n{}["API_KEY"]"#, secret)),
626 "missing secret node line in: {mer}"
627 );
628 assert!(
629 mer.contains(&format!("n{} -->|has_access_to| n{}", step, secret)),
630 "missing edge line in: {mer}"
631 );
632 assert!(
633 !mer.starts_with("%%"),
634 "complete graph should not lead with partiality comment: {mer}"
635 );
636 }
637
638 #[test]
639 fn mermaid_partial_graph_leads_with_completeness_comment() {
640 let mut g = AuthorityGraph::new(source("ci.yml"));
641 let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
642 let step = g.add_node(NodeKind::Step, "s", TrustZone::FirstParty);
643 g.add_edge(step, secret, EdgeKind::HasAccessTo);
644 g.mark_partial("fixture: unresolved composite");
645
646 let mer = render_mermaid(&g, None);
647 assert!(
648 mer.starts_with("%% taudit: authority graph is not Complete"),
649 "expected partiality banner: {mer}"
650 );
651 }
652
653 #[test]
654 fn mermaid_job_filter_matches_dot_subset() {
655 let mut g = AuthorityGraph::new(source("ci.yml"));
656
657 let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
658 let mut build_meta = std::collections::HashMap::new();
659 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
660 let build_step =
661 g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
662 g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
663
664 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
665 let mut deploy_meta = std::collections::HashMap::new();
666 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
667 let deploy_step =
668 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
669 g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
670
671 let full = render_mermaid(&g, None);
672 let filtered = render_mermaid(&g, Some("build"));
673
674 assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
675 assert!(filtered.contains("BUILD_SECRET"));
676 assert!(
677 !filtered.contains("DEPLOY_SECRET"),
678 "deploy-job nodes leaked into build filter: {filtered}"
679 );
680 assert!(!filtered.contains(r#"("ship")"#));
681
682 assert!(full.lines().count() > filtered.lines().count());
683 }
684
685 #[test]
686 fn mermaid_escapes_injection_like_node_names() {
687 let mut g = AuthorityGraph::new(source("ci.yml"));
688 let secret = g.add_node(NodeKind::Secret, "X\"]; evil", TrustZone::FirstParty);
689 let step = g.add_node(NodeKind::Step, "a", TrustZone::FirstParty);
690 g.add_edge(step, secret, EdgeKind::HasAccessTo);
691
692 let mer = render_mermaid(&g, None);
693 assert!(mer.contains("""), "expected entity escape in: {mer}");
696 let secret_line = mer
697 .lines()
698 .find(|l| l.contains('[') && l.contains("evil"))
699 .expect("secret node line");
700 assert!(
701 !secret_line.contains(r#"["X"]"#) && !secret_line.contains(r#"X"];"#),
702 "unexpected unescaped delimiters: {secret_line}"
703 );
704 }
705
706 #[test]
707 fn job_filter_produces_subset_of_full_map() {
708 let mut g = AuthorityGraph::new(source("ci.yml"));
712
713 let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
714 let mut build_meta = std::collections::HashMap::new();
715 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
716 let build_step =
717 g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
718 g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
719
720 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
721 let mut deploy_meta = std::collections::HashMap::new();
722 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
723 let deploy_step =
724 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
725 g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
726
727 let full = render_dot(&g, None);
728 let filtered = render_dot(&g, Some("build"));
729
730 assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
732 assert!(filtered.contains("BUILD_SECRET"));
733 assert!(
734 !filtered.contains("DEPLOY_SECRET"),
735 "deploy-job nodes leaked into build filter: {filtered}"
736 );
737 assert!(!filtered.contains("\"ship\""));
738
739 let full_lines = full.lines().count();
741 let filtered_lines = filtered.lines().count();
742 assert!(
743 filtered_lines < full_lines,
744 "filtered DOT ({filtered_lines} lines) not smaller than full ({full_lines})"
745 );
746 }
747
748 #[test]
749 fn job_names_lists_distinct_jobs_sorted() {
750 let mut g = AuthorityGraph::new(source("ci.yml"));
751 let mut a_meta = std::collections::HashMap::new();
752 a_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
753 g.add_node_with_metadata(NodeKind::Step, "s1", TrustZone::FirstParty, a_meta);
754 let mut b_meta = std::collections::HashMap::new();
755 b_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
756 g.add_node_with_metadata(NodeKind::Step, "s2", TrustZone::FirstParty, b_meta);
757 let mut c_meta = std::collections::HashMap::new();
758 c_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
759 g.add_node_with_metadata(NodeKind::Step, "s3", TrustZone::FirstParty, c_meta);
760
761 let names = job_names(&g);
762 assert_eq!(names, vec!["build".to_string(), "deploy".to_string()]);
763 }
764}