Skip to main content

taudit_core/
map.rs

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/// 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/// DOT shape for a node kind. Stable mapping — referenced by tests and docs.
279fn 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
289/// DOT color for a trust zone. Green/yellow/red ladder by descending trust.
290fn dot_color(zone: TrustZone) -> &'static str {
291    match zone {
292        TrustZone::FirstParty => "green",
293        TrustZone::ThirdParty => "yellow",
294        TrustZone::Untrusted => "red",
295    }
296}
297
298/// Snake-case label for an edge kind. Keeps DOT output grep-able and matches
299/// the constant names downstream tooling already understands.
300fn 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
311/// Escape a string for safe inclusion inside a DOT double-quoted literal.
312/// DOT spec: backslash and double-quote must be escaped; newlines become `\n`.
313fn 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
326/// Build the set of node ids reachable (in either direction) from any seed
327/// node, treating edges as undirected for the purpose of subgraph extraction.
328/// This is what `--job <name>` filtering uses: start from every Step in the
329/// requested job, then expand outward to capture every secret, identity,
330/// image, and artifact transitively connected to that job's authority surface.
331fn 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
358/// Render the authority graph as a Graphviz DOT digraph string.
359///
360/// When `filter_job` is `Some(name)`, restricts the output to the subgraph
361/// reachable (in either edge direction) from any Step node whose
362/// `META_JOB_NAME` metadata equals `name`. When `None`, includes every node
363/// and edge.
364///
365/// Output is deterministic — nodes and edges are emitted in their stored
366/// (insertion) order, which makes the result diff-friendly and testable.
367pub 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
422/// Escape a string for safe use inside a Mermaid flowchart **node** or **edge**
423/// label (GitHub-Flavored Markdown renderer). We avoid raw `[` `]` `|` and
424/// HTML special characters in the emitted source so diagrams stay parseable.
425fn 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("&amp;"),
430            '<' => out.push_str("&lt;"),
431            '>' => out.push_str("&gt;"),
432            '"' => out.push_str("&quot;"),
433            '\n' | '\r' => out.push(' '),
434            // Break Mermaid / Markdown delimiters
435            '|' => out.push_str("&#124;"),
436            '[' => out.push_str("&#91;"),
437            ']' => out.push_str("&#93;"),
438            '{' | '}' => out.push('·'),
439            _ => out.push(c),
440        }
441    }
442    out
443}
444
445/// Mermaid `flowchart` node line for a single graph node, matching DOT shape
446/// intent (step ≈ rounded, box, diamond, …).
447fn 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
459/// Render the authority graph as a Mermaid `flowchart LR` (parity with
460/// `render_dot`'s `rankdir=LR`).
461///
462/// `filter_job` uses the same reachability semantics as [`render_dot`]. When
463/// the graph is not [`AuthorityCompleteness::Complete`], a leading `%%`
464/// comment notes partiality; JSON export remains the source of detail.
465pub 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
513/// Distinct job names attached to Step nodes via `META_JOB_NAME`.
514/// Sorted alphabetically — used to render helpful error messages when a
515/// user passes `--job <name>` that doesn't match any step.
516pub 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        // build has access to both
560        let build_row = &map.rows[0];
561        assert!(build_row.access[0]); // API_KEY
562        assert!(build_row.access[1]); // GITHUB_TOKEN
563
564        // deploy has access to token only
565        let deploy_row = &map.rows[1];
566        assert!(!deploy_row.access[0]); // no API_KEY
567        assert!(deploy_row.access[1]); // GITHUB_TOKEN
568    }
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        // Node lines for both endpoints with their kind-driven shapes.
594        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        // Edge line with snake-case label.
603        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        // The embedded quote must be entity-encoded, not a raw `"` that could
694        // break out of the Mermaid `["..."]` label.
695        assert!(mer.contains("&quot;"), "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        // Construct two jobs by hand: `build` (step accesses BUILD_SECRET) and
709        // `deploy` (step accesses DEPLOY_SECRET). With no filter all 4 nodes
710        // appear; filtering to `build` should drop the deploy step + its secret.
711        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        // Full output names every node; filtered output drops the deploy job.
731        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        // Subset by line count: filtered must be strictly smaller than full.
740        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}