Skip to main content

taudit_core/
map.rs

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/// A row in the authority map: one step and its authority grants.
8#[derive(Debug)]
9pub struct MapRow {
10    pub step_name: String,
11    pub trust_zone: String,
12    /// Index into the `authorities` Vec — true if this step has access.
13    pub access: Vec<bool>,
14}
15
16/// Authority map: which steps have access to which secrets/identities.
17#[derive(Debug)]
18pub struct AuthorityMap {
19    /// Column headers: authority source names (secrets + identities).
20    pub authorities: Vec<String>,
21    /// One row per step.
22    pub rows: Vec<MapRow>,
23}
24
25/// Build the authority map from a graph.
26pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
27    // Collect authority sources with all metadata needed for disambiguation.
28    // Two-pass approach: first gather raw data, then detect collisions and qualify.
29    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    // Pass 1: count name occurrences to detect collisions.
47    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    // Pass 2: build display names, qualifying any that collide.
53    // Track (name, qualifier) occurrences so we can append numeric suffixes
54    // when the qualifier alone still collides.
55    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                // Unique name — no qualifier needed.
73                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                    // Qualifier alone is sufficient.
85                    format!("{} ({})", r.name, qualifier)
86                } else {
87                    // Multiple share the same qualifier — append numeric index.
88                    format!("{} ({}#{})", r.name, qualifier, idx)
89                }
90            }
91        })
92        .collect();
93
94    // `authorities` keeps the original node name for internal lookup;
95    // `authority_names` holds the qualified display names used for rendering.
96    let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
97
98    // Build rows for each step
99    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            // Find which authority column this maps to
108            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
126/// Abbreviate a trust-zone debug string to a 2-char code so the zone column
127/// stays narrow regardless of the variant name length.
128fn zone_abbr(zone: &str) -> &'static str {
129    match zone {
130        "FirstParty" => "1P",
131        "ThirdParty" => "3P",
132        _ => "?",
133    }
134}
135
136/// Truncate a string to at most `max` chars, appending `…` when cut.
137fn 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
148/// Render the authority map as a formatted table string.
149///
150/// `term_width` controls column-group pagination. Authority columns are
151/// packed left-to-right into groups narrow enough to fit; each group is
152/// emitted as a separate mini-table with a "(columns M–N of T)" label.
153/// Pass `usize::MAX` to disable pagination.
154pub 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    // Fixed left columns: Step (capped) + Zone (always "1P"/"3P"/"?", 2 chars)
160    const MAX_STEP: usize = 28;
161    const MAX_COL: usize = 18;
162    const ZONE_W: usize = 4; // "Zone" header
163
164    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    // "Step  Zone  " prefix width (step + 2 spaces + zone + 2 spaces)
173    let prefix_width = step_width + 2 + ZONE_W + 2;
174
175    // Build display names for authority columns, capped to MAX_COL.
176    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    // Each authority column occupies: display_name_width + 2 (leading spaces).
183    let auth_widths: Vec<usize> = display_names
184        .iter()
185        .map(|a| a.chars().count().max(3))
186        .collect();
187
188    // Split authorities into column groups that fit inside term_width.
189    // Each group: prefix_width + sum(auth_widths[i] + 2).
190    // Always include at least 1 column per group to avoid stalling.
191    let total_cols = auth_widths.len();
192    let mut groups: Vec<(usize, usize)> = Vec::new(); // (start_idx, end_idx exclusive)
193    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        // Header row
223        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        // Separator
239        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        // Data rows
249        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// ── Graphviz DOT rendering ────────────────────────────────
277
278/// How much context to embed in diagram node labels (DOT / Mermaid).
279///
280/// [`DiagramLabelDetail::Compact`] preserves historical default: node name only.
281/// [`DiagramLabelDetail::Rich`] appends trust zone and selected `metadata` fields
282/// already present on nodes (`identity_scope`, `permissions`) — no new graph logic.
283#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
284pub enum DiagramLabelDetail {
285    #[default]
286    Compact,
287    Rich,
288}
289
290/// Optional aggregation for DOT authority graphs (ADR 0002 Phase 4).
291#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
292pub enum DotJobCollapse {
293    #[default]
294    Off,
295    /// Merge every [`NodeKind::Step`] in the same `META_JOB_NAME` bucket into one node per job.
296    On,
297}
298
299#[derive(Clone, Copy)]
300enum RichLabelLayout {
301    /// Newline-separated blocks (Graphviz `label` string with escaped `\n`).
302    DotMultiline,
303    /// Single-line segments joined for Mermaid (avoids raw `<br/>` vs HTML escapes).
304    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
343/// DOT shape for a node kind. Stable mapping — referenced by tests and docs.
344fn 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
354/// DOT color for a trust zone. Green/yellow/red ladder by descending trust.
355fn dot_color(zone: TrustZone) -> &'static str {
356    match zone {
357        TrustZone::FirstParty => "green",
358        TrustZone::ThirdParty => "yellow",
359        TrustZone::Untrusted => "red",
360    }
361}
362
363/// Snake-case label for an edge kind. Keeps DOT output grep-able and matches
364/// the constant names downstream tooling already understands.
365fn 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
376/// Escape a string for safe inclusion inside a DOT double-quoted literal.
377/// DOT spec: backslash and double-quote must be escaped; newlines become `\n`.
378fn 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
391/// Build the set of node ids reachable (in either direction) from any seed
392/// node, treating edges as undirected for the purpose of subgraph extraction.
393/// This is what `--job <name>` filtering uses: start from every Step in the
394/// requested job, then expand outward to capture every secret, identity,
395/// image, and artifact transitively connected to that job's authority surface.
396fn 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
423/// Render the authority graph as a Graphviz DOT digraph string.
424///
425/// When `filter_job` is `Some(name)`, restricts the output to the subgraph
426/// reachable (in either edge direction) from any Step node whose
427/// `META_JOB_NAME` metadata equals `name`. When `None`, includes every node
428/// and edge.
429///
430/// Output is deterministic — nodes and edges are emitted in their stored
431/// (insertion) order, which makes the result diff-friendly and testable.
432///
433/// `label_detail` controls optional rich labels; [`DiagramLabelDetail::Compact`]
434/// preserves the historical default (node name only).
435///
436/// When `job_collapse` is [`DotJobCollapse::On`], every step in the same
437/// `META_JOB_NAME` bucket is drawn as one ellipse inside a `subgraph cluster_*`
438/// (Graphviz cluster per job). Non-step nodes keep their canonical `n<id>` ids.
439pub 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
709/// Escape a string for safe use inside a Mermaid flowchart **node** or **edge**
710/// label (GitHub-Flavored Markdown renderer). We avoid raw `[` `]` `|` and
711/// HTML special characters in the emitted source so diagrams stay parseable.
712fn 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("&amp;"),
717            '<' => out.push_str("&lt;"),
718            '>' => out.push_str("&gt;"),
719            '"' => out.push_str("&quot;"),
720            '\n' | '\r' => out.push(' '),
721            // Break Mermaid / Markdown delimiters
722            '|' => out.push_str("&#124;"),
723            '[' => out.push_str("&#91;"),
724            ']' => out.push_str("&#93;"),
725            '{' | '}' => out.push('·'),
726            _ => out.push(c),
727        }
728    }
729    out
730}
731
732/// Mermaid `flowchart` node line for a single graph node, matching DOT shape
733/// intent (step ≈ rounded, box, diamond, …).
734fn 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
746/// Render the authority graph as a Mermaid `flowchart LR` (parity with
747/// `render_dot`'s `rankdir=LR`).
748///
749/// `filter_job` uses the same reachability semantics as [`render_dot`]. When
750/// the graph is not [`AuthorityCompleteness::Complete`], a leading `%%`
751/// comment notes partiality; JSON export remains the source of detail.
752///
753/// `label_detail` matches [`render_dot`] (compact vs rich node labels).
754pub 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
808/// Distinct job names attached to Step nodes via `META_JOB_NAME`.
809/// Sorted alphabetically — used to render helpful error messages when a
810/// user passes `--job <name>` that doesn't match any step.
811pub 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        // build has access to both
855        let build_row = &map.rows[0];
856        assert!(build_row.access[0]); // API_KEY
857        assert!(build_row.access[1]); // GITHUB_TOKEN
858
859        // deploy has access to token only
860        let deploy_row = &map.rows[1];
861        assert!(!deploy_row.access[0]); // no API_KEY
862        assert!(deploy_row.access[1]); // GITHUB_TOKEN
863    }
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        // Node lines for both endpoints with their kind-driven shapes.
889        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        // Edge line with snake-case label.
898        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        // An unresolved composite action breaks the authority chain — that's
940        // a Structural gap, not an Expression-level value-hiding one.
941        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        // The embedded quote must be entity-encoded, not a raw `"` that could
1041        // break out of the Mermaid `["..."]` label.
1042        assert!(mer.contains("&quot;"), "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        // Construct two jobs by hand: `build` (step accesses BUILD_SECRET) and
1056        // `deploy` (step accesses DEPLOY_SECRET). With no filter all 4 nodes
1057        // appear; filtering to `build` should drop the deploy step + its secret.
1058        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        // Full output names every node; filtered output drops the deploy job.
1083        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        // Subset by line count: filtered must be strictly smaller than full.
1092        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}