1use crate::graph::{
2 AuthorityCompleteness, AuthorityGraph, EdgeKind, Node, NodeId, NodeKind, TrustZone,
3 META_IDENTITY_SCOPE, META_JOB_NAME, META_PERMISSIONS,
4};
5use std::collections::{BTreeMap, BTreeSet, HashMap, 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
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
284pub enum DiagramLabelDetail {
285 #[default]
286 Compact,
287 Rich,
288}
289
290#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
292pub enum DotJobCollapse {
293 #[default]
294 Off,
295 On,
297}
298
299#[derive(Clone, Copy)]
300enum RichLabelLayout {
301 DotMultiline,
303 MermaidInline,
305}
306
307const RICH_META_FIELD_MAX: usize = 96;
308const RICH_LABEL_MAX_DOT: usize = 512;
309const RICH_LABEL_MAX_MERMAID: usize = 280;
310
311fn diagram_node_label(
312 node: &crate::graph::Node,
313 detail: DiagramLabelDetail,
314 layout: RichLabelLayout,
315) -> String {
316 match detail {
317 DiagramLabelDetail::Compact => node.name.clone(),
318 DiagramLabelDetail::Rich => {
319 let zone = format!("{:?}", node.trust_zone);
320 let sep = match layout {
321 RichLabelLayout::DotMultiline => "\n",
322 RichLabelLayout::MermaidInline => " | ",
323 };
324 let mut parts: Vec<String> = Vec::new();
325 parts.push(node.name.clone());
326 parts.push(format!("zone: {zone}"));
327 if let Some(s) = node.metadata.get(META_IDENTITY_SCOPE) {
328 parts.push(format!("scope: {}", trunc(s, RICH_META_FIELD_MAX)));
329 }
330 if let Some(p) = node.metadata.get(META_PERMISSIONS) {
331 parts.push(format!("perm: {}", trunc(p, RICH_META_FIELD_MAX)));
332 }
333 let joined = parts.join(sep);
334 let cap = match layout {
335 RichLabelLayout::DotMultiline => RICH_LABEL_MAX_DOT,
336 RichLabelLayout::MermaidInline => RICH_LABEL_MAX_MERMAID,
337 };
338 trunc(&joined, cap)
339 }
340 }
341}
342
343fn dot_shape(kind: NodeKind) -> &'static str {
345 match kind {
346 NodeKind::Step => "ellipse",
347 NodeKind::Secret => "box",
348 NodeKind::Identity => "diamond",
349 NodeKind::Artifact => "hexagon",
350 NodeKind::Image => "cylinder",
351 }
352}
353
354fn dot_color(zone: TrustZone) -> &'static str {
356 match zone {
357 TrustZone::FirstParty => "green",
358 TrustZone::ThirdParty => "yellow",
359 TrustZone::Untrusted => "red",
360 }
361}
362
363fn edge_label(kind: EdgeKind) -> &'static str {
366 match kind {
367 EdgeKind::HasAccessTo => "has_access_to",
368 EdgeKind::Produces => "produces",
369 EdgeKind::Consumes => "consumes",
370 EdgeKind::UsesImage => "uses_image",
371 EdgeKind::DelegatesTo => "delegates_to",
372 EdgeKind::PersistsTo => "persists_to",
373 }
374}
375
376fn dot_escape(s: &str) -> String {
379 let mut out = String::with_capacity(s.len());
380 for c in s.chars() {
381 match c {
382 '\\' => out.push_str("\\\\"),
383 '"' => out.push_str("\\\""),
384 '\n' => out.push_str("\\n"),
385 _ => out.push(c),
386 }
387 }
388 out
389}
390
391fn reachable_set(graph: &AuthorityGraph, seeds: &[NodeId]) -> HashSet<NodeId> {
397 let mut visited: HashSet<NodeId> = HashSet::new();
398 let mut queue: VecDeque<NodeId> = VecDeque::new();
399 for &s in seeds {
400 if visited.insert(s) {
401 queue.push_back(s);
402 }
403 }
404 while let Some(n) = queue.pop_front() {
405 for e in &graph.edges {
406 let next = if e.from == n {
407 Some(e.to)
408 } else if e.to == n {
409 Some(e.from)
410 } else {
411 None
412 };
413 if let Some(nx) = next {
414 if visited.insert(nx) {
415 queue.push_back(nx);
416 }
417 }
418 }
419 }
420 visited
421}
422
423pub fn render_dot(
440 graph: &AuthorityGraph,
441 filter_job: Option<&str>,
442 label_detail: DiagramLabelDetail,
443 job_collapse: DotJobCollapse,
444) -> String {
445 let included: Option<HashSet<NodeId>> = match filter_job {
446 Some(name) => {
447 let seeds: Vec<NodeId> = graph
448 .nodes
449 .iter()
450 .filter(|n| {
451 n.kind == NodeKind::Step
452 && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
453 })
454 .map(|n| n.id)
455 .collect();
456 Some(reachable_set(graph, &seeds))
457 }
458 None => None,
459 };
460
461 match job_collapse {
462 DotJobCollapse::Off => render_dot_flat(graph, included, label_detail),
463 DotJobCollapse::On => render_dot_collapsed_by_job(graph, included, label_detail),
464 }
465}
466
467fn render_dot_flat(
468 graph: &AuthorityGraph,
469 included: Option<HashSet<NodeId>>,
470 label_detail: DiagramLabelDetail,
471) -> String {
472 let mut out = String::new();
473 out.push_str("digraph taudit {\n");
474 out.push_str(" rankdir=LR;\n");
475 out.push_str(" node [fontname=\"Helvetica\"];\n");
476
477 for node in &graph.nodes {
478 if let Some(ref keep) = included {
479 if !keep.contains(&node.id) {
480 continue;
481 }
482 }
483 let raw_label = diagram_node_label(node, label_detail, RichLabelLayout::DotMultiline);
484 out.push_str(&format!(
485 " \"n{}\" [label=\"{}\" shape={} color={}];\n",
486 node.id,
487 dot_escape(&raw_label),
488 dot_shape(node.kind),
489 dot_color(node.trust_zone),
490 ));
491 }
492
493 for edge in &graph.edges {
494 if let Some(ref keep) = included {
495 if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
496 continue;
497 }
498 }
499 out.push_str(&format!(
500 " \"n{}\" -> \"n{}\" [label=\"{}\"];\n",
501 edge.from,
502 edge.to,
503 edge_label(edge.kind),
504 ));
505 }
506
507 out.push_str("}\n");
508 out
509}
510
511fn effective_included(
512 graph: &AuthorityGraph,
513 included: &Option<HashSet<NodeId>>,
514) -> HashSet<NodeId> {
515 match included {
516 Some(s) => s.clone(),
517 None => graph.nodes.iter().map(|n| n.id).collect(),
518 }
519}
520
521fn job_bucket_key(step: &Node) -> String {
522 step.metadata
523 .get(META_JOB_NAME)
524 .cloned()
525 .unwrap_or_default()
526}
527
528fn job_subgraph_title(key: &str) -> String {
529 if key.is_empty() {
530 "(no job)".to_string()
531 } else {
532 key.to_string()
533 }
534}
535
536fn collapsed_job_node_label(
537 job_title: &str,
538 step_count: usize,
539 worst_zone: TrustZone,
540 label_detail: DiagramLabelDetail,
541) -> String {
542 let steps_note = if step_count == 1 {
543 "1 step".to_string()
544 } else {
545 format!("{step_count} steps")
546 };
547 match label_detail {
548 DiagramLabelDetail::Compact => {
549 format!("job: {job_title}\n({steps_note})")
550 }
551 DiagramLabelDetail::Rich => {
552 let zone = format!("{worst_zone:?}");
553 format!("job: {job_title}\n({steps_note})\nzone: {zone}")
554 }
555 }
556}
557
558fn min_trust_zone<'a, I: Iterator<Item = &'a TrustZone>>(zones: I) -> TrustZone {
559 zones.fold(TrustZone::FirstParty, |acc, z| {
560 if z.is_lower_than(&acc) {
561 *z
562 } else {
563 acc
564 }
565 })
566}
567
568fn render_dot_collapsed_by_job(
569 graph: &AuthorityGraph,
570 included: Option<HashSet<NodeId>>,
571 label_detail: DiagramLabelDetail,
572) -> String {
573 let eff = effective_included(graph, &included);
574
575 let mut job_keys: Vec<String> = graph
576 .nodes
577 .iter()
578 .filter(|n| n.kind == NodeKind::Step && eff.contains(&n.id))
579 .map(job_bucket_key)
580 .collect::<HashSet<_>>()
581 .into_iter()
582 .collect();
583 job_keys.sort();
584
585 let job_index: HashMap<String, usize> = job_keys
586 .iter()
587 .enumerate()
588 .map(|(i, k)| (k.clone(), i))
589 .collect();
590
591 let mut steps_per_job: HashMap<String, Vec<&Node>> = HashMap::new();
592 for n in &graph.nodes {
593 if n.kind != NodeKind::Step || !eff.contains(&n.id) {
594 continue;
595 }
596 let k = job_bucket_key(n);
597 steps_per_job.entry(k).or_default().push(n);
598 }
599
600 let node_by_id: HashMap<NodeId, &Node> = graph.nodes.iter().map(|n| (n.id, n)).collect();
601
602 let mut collapsed_edges: BTreeMap<(String, String), BTreeSet<EdgeKind>> = BTreeMap::new();
603
604 let step_dot_id = |step_id: NodeId| -> Option<String> {
605 let n = node_by_id.get(&step_id)?;
606 if n.kind != NodeKind::Step {
607 return None;
608 }
609 let idx = *job_index.get(&job_bucket_key(n))?;
610 Some(format!("jb{idx}"))
611 };
612
613 let non_step_dot_id = |id: NodeId| -> Option<String> {
614 let n = node_by_id.get(&id)?;
615 if n.kind == NodeKind::Step {
616 return None;
617 }
618 if !eff.contains(&id) {
619 return None;
620 }
621 Some(format!("n{id}"))
622 };
623
624 let endpoint_id = |id: NodeId| -> Option<String> {
625 if let Some(s) = step_dot_id(id) {
626 return Some(s);
627 }
628 non_step_dot_id(id)
629 };
630
631 for e in &graph.edges {
632 if !eff.contains(&e.from) || !eff.contains(&e.to) {
633 continue;
634 }
635 let Some(a) = endpoint_id(e.from) else {
636 continue;
637 };
638 let Some(b) = endpoint_id(e.to) else { continue };
639 if a == b {
640 continue;
641 }
642 collapsed_edges
643 .entry((a.clone(), b.clone()))
644 .or_default()
645 .insert(e.kind);
646 }
647
648 let mut out = String::new();
649 out.push_str("digraph taudit {\n");
650 out.push_str(" rankdir=LR;\n");
651 out.push_str(" node [fontname=\"Helvetica\"];\n");
652
653 for (idx, job_key) in job_keys.iter().enumerate() {
654 let steps = steps_per_job
655 .get(job_key)
656 .map(|v| v.as_slice())
657 .unwrap_or(&[]);
658 let worst = min_trust_zone(steps.iter().map(|n| &n.trust_zone));
659 let title = job_subgraph_title(job_key);
660 let raw_label = collapsed_job_node_label(&title, steps.len(), worst, label_detail);
661 out.push_str(&format!(" subgraph cluster_job_{idx} {{\n"));
662 out.push_str(&format!(" label=\"job: {}\";\n", dot_escape(&title)));
663 out.push_str(" style=\"rounded\";\n");
664 out.push_str(&format!(
665 " \"jb{idx}\" [label=\"{}\" shape=ellipse color={}];\n",
666 dot_escape(&raw_label),
667 dot_color(worst),
668 ));
669 out.push_str(" }\n");
670 }
671
672 for node in &graph.nodes {
673 if node.kind == NodeKind::Step {
674 continue;
675 }
676 if !eff.contains(&node.id) {
677 continue;
678 }
679 let raw_label = diagram_node_label(node, label_detail, RichLabelLayout::DotMultiline);
680 out.push_str(&format!(
681 " \"n{}\" [label=\"{}\" shape={} color={}];\n",
682 node.id,
683 dot_escape(&raw_label),
684 dot_shape(node.kind),
685 dot_color(node.trust_zone),
686 ));
687 }
688
689 for ((from, to), kinds) in &collapsed_edges {
690 let mut kinds_v: Vec<EdgeKind> = kinds.iter().copied().collect();
691 kinds_v.sort_unstable();
692 let label = kinds_v
693 .iter()
694 .map(|k| edge_label(*k))
695 .collect::<Vec<_>>()
696 .join(", ");
697 out.push_str(&format!(
698 " \"{}\" -> \"{}\" [label=\"{}\"];\n",
699 from,
700 to,
701 dot_escape(&label),
702 ));
703 }
704
705 out.push_str("}\n");
706 out
707}
708
709fn mermaid_label_escape(s: &str) -> String {
713 let mut out = String::with_capacity(s.len() + 8);
714 for c in s.chars() {
715 match c {
716 '&' => out.push_str("&"),
717 '<' => out.push_str("<"),
718 '>' => out.push_str(">"),
719 '"' => out.push_str("""),
720 '\n' | '\r' => out.push(' '),
721 '|' => out.push_str("|"),
723 '[' => out.push_str("["),
724 ']' => out.push_str("]"),
725 '{' | '}' => out.push('·'),
726 _ => out.push(c),
727 }
728 }
729 out
730}
731
732fn mermaid_node_line(node: &crate::graph::Node, display_esc: &str) -> String {
735 let id = node.id;
736 let esc = display_esc;
737 match node.kind {
738 NodeKind::Step => format!(r#" n{id}("{esc}")"#),
739 NodeKind::Secret => format!(r#" n{id}["{esc}"]"#),
740 NodeKind::Identity => format!(r#" n{id}{{"{esc}"}}"#),
741 NodeKind::Artifact => format!(r#" n{id}[["{esc}"]]"#),
742 NodeKind::Image => format!(r#" n{id}[("{esc}")]"#),
743 }
744}
745
746pub fn render_mermaid(
755 graph: &AuthorityGraph,
756 filter_job: Option<&str>,
757 label_detail: DiagramLabelDetail,
758) -> String {
759 let included: Option<HashSet<NodeId>> = match filter_job {
760 Some(name) => {
761 let seeds: Vec<NodeId> = graph
762 .nodes
763 .iter()
764 .filter(|n| {
765 n.kind == NodeKind::Step
766 && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
767 })
768 .map(|n| n.id)
769 .collect();
770 Some(reachable_set(graph, &seeds))
771 }
772 None => None,
773 };
774
775 let mut out = String::new();
776 if graph.completeness != AuthorityCompleteness::Complete {
777 out.push_str(
778 "%% taudit: authority graph is not Complete; use JSON for completeness and gaps\n",
779 );
780 }
781 out.push_str("flowchart LR\n");
782
783 for node in &graph.nodes {
784 if let Some(ref keep) = included {
785 if !keep.contains(&node.id) {
786 continue;
787 }
788 }
789 let raw = diagram_node_label(node, label_detail, RichLabelLayout::MermaidInline);
790 let esc = mermaid_label_escape(&raw);
791 out.push_str(&mermaid_node_line(node, &esc));
792 out.push('\n');
793 }
794
795 for edge in &graph.edges {
796 if let Some(ref keep) = included {
797 if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
798 continue;
799 }
800 }
801 let el = mermaid_label_escape(edge_label(edge.kind));
802 out.push_str(&format!(" n{} -->|{}| n{}\n", edge.from, el, edge.to));
803 }
804
805 out
806}
807
808pub fn job_names(graph: &AuthorityGraph) -> Vec<String> {
812 let mut names: Vec<String> = graph
813 .nodes
814 .iter()
815 .filter(|n| n.kind == NodeKind::Step)
816 .filter_map(|n| n.metadata.get(META_JOB_NAME).cloned())
817 .collect::<HashSet<_>>()
818 .into_iter()
819 .collect();
820 names.sort();
821 names
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use crate::graph::*;
828
829 fn source(file: &str) -> PipelineSource {
830 PipelineSource {
831 file: file.into(),
832 repo: None,
833 git_ref: None,
834 commit_sha: None,
835 }
836 }
837
838 #[test]
839 fn map_shows_step_access() {
840 let mut g = AuthorityGraph::new(source("ci.yml"));
841 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
842 let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
843 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
844 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
845
846 g.add_edge(build, secret, EdgeKind::HasAccessTo);
847 g.add_edge(build, token, EdgeKind::HasAccessTo);
848 g.add_edge(deploy, token, EdgeKind::HasAccessTo);
849
850 let map = authority_map(&g);
851 assert_eq!(map.authorities.len(), 2);
852 assert_eq!(map.rows.len(), 2);
853
854 let build_row = &map.rows[0];
856 assert!(build_row.access[0]); assert!(build_row.access[1]); let deploy_row = &map.rows[1];
861 assert!(!deploy_row.access[0]); assert!(deploy_row.access[1]); }
864
865 #[test]
866 fn map_renders_table() {
867 let mut g = AuthorityGraph::new(source("ci.yml"));
868 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
869 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
870 g.add_edge(step, secret, EdgeKind::HasAccessTo);
871
872 let map = authority_map(&g);
873 let table = render_map(&map, 120);
874 assert!(table.contains("build"));
875 assert!(table.contains("KEY"));
876 assert!(table.contains('✓'));
877 }
878
879 #[test]
880 fn dot_output_contains_expected_node_and_edge() {
881 let mut g = AuthorityGraph::new(source("ci.yml"));
882 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
883 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
884 g.add_edge(step, secret, EdgeKind::HasAccessTo);
885
886 let dot = render_dot(&g, None, DiagramLabelDetail::Compact, DotJobCollapse::Off);
887 assert!(dot.starts_with("digraph taudit"), "dot output: {dot}");
888 assert!(
890 dot.contains(&format!("\"n{step}\" [label=\"build\" shape=ellipse")),
891 "missing step node line in: {dot}"
892 );
893 assert!(
894 dot.contains(&format!("\"n{secret}\" [label=\"API_KEY\" shape=box")),
895 "missing secret node line in: {dot}"
896 );
897 assert!(
899 dot.contains(&format!(
900 "\"n{step}\" -> \"n{secret}\" [label=\"has_access_to\"]"
901 )),
902 "missing edge line in: {dot}"
903 );
904 }
905
906 #[test]
907 fn mermaid_output_contains_expected_node_and_edge() {
908 let mut g = AuthorityGraph::new(source("ci.yml"));
909 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
910 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
911 g.add_edge(step, secret, EdgeKind::HasAccessTo);
912
913 let mer = render_mermaid(&g, None, DiagramLabelDetail::Compact);
914 assert!(mer.starts_with("flowchart LR"), "mermaid output: {mer}");
915 assert!(
916 mer.contains(&format!(r#"n{step}("build")"#)),
917 "missing step node line in: {mer}"
918 );
919 assert!(
920 mer.contains(&format!(r#"n{secret}["API_KEY"]"#)),
921 "missing secret node line in: {mer}"
922 );
923 assert!(
924 mer.contains(&format!("n{step} -->|has_access_to| n{secret}")),
925 "missing edge line in: {mer}"
926 );
927 assert!(
928 !mer.starts_with("%%"),
929 "complete graph should not lead with partiality comment: {mer}"
930 );
931 }
932
933 #[test]
934 fn mermaid_partial_graph_leads_with_completeness_comment() {
935 let mut g = AuthorityGraph::new(source("ci.yml"));
936 let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
937 let step = g.add_node(NodeKind::Step, "s", TrustZone::FirstParty);
938 g.add_edge(step, secret, EdgeKind::HasAccessTo);
939 g.mark_partial(GapKind::Structural, "fixture: unresolved composite");
942
943 assert_eq!(
944 g.completeness_gap_kinds,
945 vec![GapKind::Structural],
946 "unresolved-composite gap must be classified as Structural"
947 );
948
949 let mer = render_mermaid(&g, None, DiagramLabelDetail::Compact);
950 assert!(
951 mer.starts_with("%% taudit: authority graph is not Complete"),
952 "expected partiality banner: {mer}"
953 );
954 }
955
956 #[test]
957 fn mermaid_job_filter_matches_dot_subset() {
958 let mut g = AuthorityGraph::new(source("ci.yml"));
959
960 let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
961 let mut build_meta = std::collections::HashMap::new();
962 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
963 let build_step =
964 g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
965 g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
966
967 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
968 let mut deploy_meta = std::collections::HashMap::new();
969 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
970 let deploy_step =
971 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
972 g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
973
974 let full = render_mermaid(&g, None, DiagramLabelDetail::Compact);
975 let filtered = render_mermaid(&g, Some("build"), DiagramLabelDetail::Compact);
976
977 assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
978 assert!(filtered.contains("BUILD_SECRET"));
979 assert!(
980 !filtered.contains("DEPLOY_SECRET"),
981 "deploy-job nodes leaked into build filter: {filtered}"
982 );
983 assert!(!filtered.contains(r#"("ship")"#));
984
985 assert!(full.lines().count() > filtered.lines().count());
986 }
987
988 #[test]
989 fn rich_dot_and_mermaid_include_zone_and_optional_metadata() {
990 let mut g = AuthorityGraph::new(source("ci.yml"));
991 let mut id_meta = std::collections::HashMap::new();
992 id_meta.insert(META_IDENTITY_SCOPE.to_string(), "constrained".to_string());
993 id_meta.insert(
994 META_PERMISSIONS.to_string(),
995 "{ contents: read }".to_string(),
996 );
997 let id = g.add_node_with_metadata(
998 NodeKind::Identity,
999 "GITHUB_TOKEN",
1000 TrustZone::FirstParty,
1001 id_meta,
1002 );
1003 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1004 g.add_edge(step, id, EdgeKind::HasAccessTo);
1005
1006 let dot = render_dot(&g, None, DiagramLabelDetail::Rich, DotJobCollapse::Off);
1007 assert!(
1008 dot.contains("zone: FirstParty"),
1009 "rich dot should include zone: {dot}"
1010 );
1011 assert!(
1012 dot.contains("scope: constrained"),
1013 "rich dot should include identity scope: {dot}"
1014 );
1015 assert!(
1016 dot.contains("perm:"),
1017 "rich dot should include permissions summary: {dot}"
1018 );
1019
1020 let mer = render_mermaid(&g, None, DiagramLabelDetail::Rich);
1021 assert!(mer.contains("zone: FirstParty"), "rich mermaid: {mer}");
1022 assert!(mer.contains("scope: constrained"), "rich mermaid: {mer}");
1023 assert!(mer.contains("perm:"), "rich mermaid: {mer}");
1024
1025 let mer_c = render_mermaid(&g, None, DiagramLabelDetail::Compact);
1026 assert!(
1027 !mer_c.contains("zone: FirstParty"),
1028 "compact must not add zone line: {mer_c}"
1029 );
1030 }
1031
1032 #[test]
1033 fn mermaid_escapes_injection_like_node_names() {
1034 let mut g = AuthorityGraph::new(source("ci.yml"));
1035 let secret = g.add_node(NodeKind::Secret, "X\"]; evil", TrustZone::FirstParty);
1036 let step = g.add_node(NodeKind::Step, "a", TrustZone::FirstParty);
1037 g.add_edge(step, secret, EdgeKind::HasAccessTo);
1038
1039 let mer = render_mermaid(&g, None, DiagramLabelDetail::Compact);
1040 assert!(mer.contains("""), "expected entity escape in: {mer}");
1043 let secret_line = mer
1044 .lines()
1045 .find(|l| l.contains('[') && l.contains("evil"))
1046 .expect("secret node line");
1047 assert!(
1048 !secret_line.contains(r#"["X"]"#) && !secret_line.contains(r#"X"];"#),
1049 "unexpected unescaped delimiters: {secret_line}"
1050 );
1051 }
1052
1053 #[test]
1054 fn job_filter_produces_subset_of_full_map() {
1055 let mut g = AuthorityGraph::new(source("ci.yml"));
1059
1060 let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
1061 let mut build_meta = std::collections::HashMap::new();
1062 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
1063 let build_step =
1064 g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
1065 g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
1066
1067 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
1068 let mut deploy_meta = std::collections::HashMap::new();
1069 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
1070 let deploy_step =
1071 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
1072 g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
1073
1074 let full = render_dot(&g, None, DiagramLabelDetail::Compact, DotJobCollapse::Off);
1075 let filtered = render_dot(
1076 &g,
1077 Some("build"),
1078 DiagramLabelDetail::Compact,
1079 DotJobCollapse::Off,
1080 );
1081
1082 assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
1084 assert!(filtered.contains("BUILD_SECRET"));
1085 assert!(
1086 !filtered.contains("DEPLOY_SECRET"),
1087 "deploy-job nodes leaked into build filter: {filtered}"
1088 );
1089 assert!(!filtered.contains("\"ship\""));
1090
1091 let full_lines = full.lines().count();
1093 let filtered_lines = filtered.lines().count();
1094 assert!(
1095 filtered_lines < full_lines,
1096 "filtered DOT ({filtered_lines} lines) not smaller than full ({full_lines})"
1097 );
1098 }
1099
1100 #[test]
1101 fn dot_job_collapse_emits_cluster_per_job_and_merges_step_edges() {
1102 let mut g = AuthorityGraph::new(source("ci.yml"));
1103 let shared = g.add_node(NodeKind::Secret, "SHARED", TrustZone::FirstParty);
1104
1105 let mut build_meta = std::collections::HashMap::new();
1106 build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
1107 let s1 = g.add_node_with_metadata(
1108 NodeKind::Step,
1109 "compile",
1110 TrustZone::FirstParty,
1111 build_meta.clone(),
1112 );
1113 let s2 =
1114 g.add_node_with_metadata(NodeKind::Step, "lint", TrustZone::ThirdParty, build_meta);
1115 g.add_edge(s1, shared, EdgeKind::HasAccessTo);
1116 g.add_edge(s2, shared, EdgeKind::HasAccessTo);
1117
1118 let mut deploy_meta = std::collections::HashMap::new();
1119 deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
1120 let s3 =
1121 g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
1122 let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1123 g.add_edge(s3, deploy_secret, EdgeKind::HasAccessTo);
1124
1125 let flat = render_dot(&g, None, DiagramLabelDetail::Compact, DotJobCollapse::Off);
1126 let collapsed = render_dot(&g, None, DiagramLabelDetail::Compact, DotJobCollapse::On);
1127
1128 assert!(
1129 flat.contains("compile") && flat.contains("lint"),
1130 "flat dot should name each step: {flat}"
1131 );
1132 assert!(
1133 !collapsed.contains("compile") && !collapsed.contains("lint"),
1134 "collapsed dot should not repeat per-step names: {collapsed}"
1135 );
1136 assert!(
1137 collapsed.contains("subgraph cluster_job_0"),
1138 "expected cluster subgraph: {collapsed}"
1139 );
1140 assert!(
1141 collapsed.contains("subgraph cluster_job_1"),
1142 "expected second cluster: {collapsed}"
1143 );
1144 assert!(
1145 collapsed.contains("label=\"job: build\"")
1146 && collapsed.contains("label=\"job: deploy\""),
1147 "cluster titles: {collapsed}"
1148 );
1149 assert!(
1150 collapsed.contains(&format!("\"jb0\" -> \"n{shared}\"")),
1151 "merged edge from build job bucket to secret: {collapsed}"
1152 );
1153 assert!(
1154 collapsed.lines().filter(|l| l.contains("ellipse")).count()
1155 < flat.lines().filter(|l| l.contains("ellipse")).count(),
1156 "collapsed should have fewer ellipse nodes than flat"
1157 );
1158 }
1159
1160 #[test]
1161 fn job_names_lists_distinct_jobs_sorted() {
1162 let mut g = AuthorityGraph::new(source("ci.yml"));
1163 let mut a_meta = std::collections::HashMap::new();
1164 a_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
1165 g.add_node_with_metadata(NodeKind::Step, "s1", TrustZone::FirstParty, a_meta);
1166 let mut b_meta = std::collections::HashMap::new();
1167 b_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
1168 g.add_node_with_metadata(NodeKind::Step, "s2", TrustZone::FirstParty, b_meta);
1169 let mut c_meta = std::collections::HashMap::new();
1170 c_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
1171 g.add_node_with_metadata(NodeKind::Step, "s3", TrustZone::FirstParty, c_meta);
1172
1173 let names = job_names(&g);
1174 assert_eq!(names, vec!["build".to_string(), "deploy".to_string()]);
1175 }
1176}