Skip to main content

taudit_core/
map.rs

1use crate::graph::{
2    AuthorityGraph, EdgeKind, NodeId, NodeKind, TrustZone, META_IDENTITY_SCOPE, META_JOB_NAME,
3};
4use std::collections::{HashSet, VecDeque};
5
6/// A row in the authority map: one step and its authority grants.
7#[derive(Debug)]
8pub struct MapRow {
9    pub step_name: String,
10    pub trust_zone: String,
11    /// Index into the `authorities` Vec — true if this step has access.
12    pub access: Vec<bool>,
13}
14
15/// Authority map: which steps have access to which secrets/identities.
16#[derive(Debug)]
17pub struct AuthorityMap {
18    /// Column headers: authority source names (secrets + identities).
19    pub authorities: Vec<String>,
20    /// One row per step.
21    pub rows: Vec<MapRow>,
22}
23
24/// Build the authority map from a graph.
25pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
26    // Collect authority sources with all metadata needed for disambiguation.
27    // Two-pass approach: first gather raw data, then detect collisions and qualify.
28    struct RawAuthority {
29        id: NodeId,
30        name: String,
31        zone: String,
32        scope: Option<String>,
33    }
34
35    let raw: Vec<RawAuthority> = graph
36        .authority_sources()
37        .map(|n| RawAuthority {
38            id: n.id,
39            name: n.name.clone(),
40            zone: format!("{:?}", n.trust_zone),
41            scope: n.metadata.get(META_IDENTITY_SCOPE).cloned(),
42        })
43        .collect();
44
45    // Pass 1: count name occurrences to detect collisions.
46    let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
47    for r in &raw {
48        *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
49    }
50
51    // Pass 2: build display names, qualifying any that collide.
52    // Track (name, qualifier) occurrences so we can append numeric suffixes
53    // when the qualifier alone still collides.
54    let mut qualifier_counts: std::collections::HashMap<(String, String), usize> =
55        std::collections::HashMap::new();
56    for r in &raw {
57        if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
58            let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
59            *qualifier_counts
60                .entry((r.name.clone(), qualifier))
61                .or_insert(0) += 1;
62        }
63    }
64
65    let mut seen: std::collections::HashMap<(String, String), usize> =
66        std::collections::HashMap::new();
67    let authority_names: Vec<String> = raw
68        .iter()
69        .map(|r| {
70            if name_counts.get(r.name.as_str()).copied().unwrap_or(0) <= 1 {
71                // Unique name — no qualifier needed.
72                r.name.clone()
73            } else {
74                let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
75                let key = (r.name.clone(), qualifier.clone());
76                let total_with_qualifier = qualifier_counts.get(&key).copied().unwrap_or(1);
77                let idx = {
78                    let entry = seen.entry(key).or_insert(0);
79                    *entry += 1;
80                    *entry
81                };
82                if total_with_qualifier <= 1 {
83                    // Qualifier alone is sufficient.
84                    format!("{} ({})", r.name, qualifier)
85                } else {
86                    // Multiple share the same qualifier — append numeric index.
87                    format!("{} ({}#{})", r.name, qualifier, idx)
88                }
89            }
90        })
91        .collect();
92
93    // `authorities` keeps the original node name for internal lookup;
94    // `authority_names` holds the qualified display names used for rendering.
95    let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
96
97    // Build rows for each step
98    let mut rows = Vec::new();
99    for step in graph.nodes_of_kind(NodeKind::Step) {
100        let mut access = vec![false; authorities.len()];
101
102        for edge in graph.edges_from(step.id) {
103            if edge.kind != EdgeKind::HasAccessTo {
104                continue;
105            }
106            // Find which authority column this maps to
107            if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
108                access[idx] = true;
109            }
110        }
111
112        rows.push(MapRow {
113            step_name: step.name.clone(),
114            trust_zone: format!("{:?}", step.trust_zone),
115            access,
116        });
117    }
118
119    AuthorityMap {
120        authorities: authority_names,
121        rows,
122    }
123}
124
125/// Abbreviate a trust-zone debug string to a 2-char code so the zone column
126/// stays narrow regardless of the variant name length.
127fn zone_abbr(zone: &str) -> &'static str {
128    match zone {
129        "FirstParty" => "1P",
130        "ThirdParty" => "3P",
131        _ => "?",
132    }
133}
134
135/// Truncate a string to at most `max` chars, appending `…` when cut.
136fn trunc(s: &str, max: usize) -> String {
137    let n = s.chars().count();
138    if n <= max {
139        s.to_string()
140    } else {
141        let mut out: String = s.chars().take(max - 1).collect();
142        out.push('…');
143        out
144    }
145}
146
147/// Render the authority map as a formatted table string.
148///
149/// `term_width` controls column-group pagination. Authority columns are
150/// packed left-to-right into groups narrow enough to fit; each group is
151/// emitted as a separate mini-table with a "(columns M–N of T)" label.
152/// Pass `usize::MAX` to disable pagination.
153pub fn render_map(map: &AuthorityMap, term_width: usize) -> String {
154    if map.rows.is_empty() && map.authorities.is_empty() {
155        return "No steps or authority sources found.\n".to_string();
156    }
157
158    // Fixed left columns: Step (capped) + Zone (always "1P"/"3P"/"?", 2 chars)
159    const MAX_STEP: usize = 28;
160    const MAX_COL: usize = 18;
161    const ZONE_W: usize = 4; // "Zone" header
162
163    let step_width = map
164        .rows
165        .iter()
166        .map(|r| r.step_name.chars().count().min(MAX_STEP))
167        .max()
168        .unwrap_or(4)
169        .max(4);
170
171    // "Step  Zone  " prefix width (step + 2 spaces + zone + 2 spaces)
172    let prefix_width = step_width + 2 + ZONE_W + 2;
173
174    // Build display names for authority columns, capped to MAX_COL.
175    let display_names: Vec<String> = map.authorities.iter().map(|a| trunc(a, MAX_COL)).collect();
176    let any_truncated = display_names
177        .iter()
178        .zip(map.authorities.iter())
179        .any(|(d, o)| d != o);
180
181    // Each authority column occupies: display_name_width + 2 (leading spaces).
182    let auth_widths: Vec<usize> = display_names
183        .iter()
184        .map(|a| a.chars().count().max(3))
185        .collect();
186
187    // Split authorities into column groups that fit inside term_width.
188    // Each group: prefix_width + sum(auth_widths[i] + 2).
189    // Always include at least 1 column per group to avoid stalling.
190    let total_cols = auth_widths.len();
191    let mut groups: Vec<(usize, usize)> = Vec::new(); // (start_idx, end_idx exclusive)
192    let mut gi = 0;
193    while gi < total_cols {
194        let mut used = prefix_width;
195        let mut end = gi;
196        while end < total_cols {
197            let next = used + auth_widths[end] + 2;
198            if next > term_width && end > gi {
199                break;
200            }
201            used = next;
202            end += 1;
203        }
204        groups.push((gi, end));
205        gi = end;
206    }
207
208    let multi_group = groups.len() > 1;
209    let mut out = String::new();
210
211    for (group_idx, &(start, end)) in groups.iter().enumerate() {
212        if multi_group {
213            out.push_str(&format!(
214                "  columns {}-{} of {}\n",
215                start + 1,
216                end,
217                total_cols
218            ));
219        }
220
221        // Header row
222        out.push_str(&format!(
223            "{:<step_w$}  {:<zone_w$}",
224            "Step",
225            "Zone",
226            step_w = step_width,
227            zone_w = ZONE_W,
228        ));
229        for (name, w) in display_names[start..end]
230            .iter()
231            .zip(&auth_widths[start..end])
232        {
233            out.push_str(&format!("  {name:^w$}"));
234        }
235        out.push('\n');
236
237        // Separator
238        out.push_str(&"-".repeat(step_width));
239        out.push_str("  ");
240        out.push_str(&"-".repeat(ZONE_W));
241        for w in &auth_widths[start..end] {
242            out.push_str("  ");
243            out.push_str(&"-".repeat(*w));
244        }
245        out.push('\n');
246
247        // Data rows
248        for row in &map.rows {
249            let step_display = trunc(&row.step_name, MAX_STEP);
250            let zone_display = zone_abbr(&row.trust_zone);
251            out.push_str(&format!(
252                "{step_display:<step_width$}  {zone_display:<ZONE_W$}"
253            ));
254            for (col, w) in auth_widths[start..end].iter().enumerate() {
255                let marker = if row.access[start + col] { "✓" } else { "·" };
256                out.push_str(&format!("  {marker:^w$}"));
257            }
258            out.push('\n');
259        }
260
261        if group_idx + 1 < groups.len() {
262            out.push('\n');
263        }
264    }
265
266    if any_truncated {
267        out.push_str(&format!(
268            "\nnote: column names truncated to {MAX_COL} chars\n"
269        ));
270    }
271
272    out
273}
274
275// ── Graphviz DOT rendering ────────────────────────────────
276
277/// DOT shape for a node kind. Stable mapping — referenced by tests and docs.
278fn dot_shape(kind: NodeKind) -> &'static str {
279    match kind {
280        NodeKind::Step => "ellipse",
281        NodeKind::Secret => "box",
282        NodeKind::Identity => "diamond",
283        NodeKind::Artifact => "hexagon",
284        NodeKind::Image => "cylinder",
285    }
286}
287
288/// DOT color for a trust zone. Green/yellow/red ladder by descending trust.
289fn dot_color(zone: TrustZone) -> &'static str {
290    match zone {
291        TrustZone::FirstParty => "green",
292        TrustZone::ThirdParty => "yellow",
293        TrustZone::Untrusted => "red",
294    }
295}
296
297/// Snake-case label for an edge kind. Keeps DOT output grep-able and matches
298/// the constant names downstream tooling already understands.
299fn edge_label(kind: EdgeKind) -> &'static str {
300    match kind {
301        EdgeKind::HasAccessTo => "has_access_to",
302        EdgeKind::Produces => "produces",
303        EdgeKind::Consumes => "consumes",
304        EdgeKind::UsesImage => "uses_image",
305        EdgeKind::DelegatesTo => "delegates_to",
306        EdgeKind::PersistsTo => "persists_to",
307    }
308}
309
310/// Escape a string for safe inclusion inside a DOT double-quoted literal.
311/// DOT spec: backslash and double-quote must be escaped; newlines become `\n`.
312fn dot_escape(s: &str) -> String {
313    let mut out = String::with_capacity(s.len());
314    for c in s.chars() {
315        match c {
316            '\\' => out.push_str("\\\\"),
317            '"' => out.push_str("\\\""),
318            '\n' => out.push_str("\\n"),
319            _ => out.push(c),
320        }
321    }
322    out
323}
324
325/// Build the set of node ids reachable (in either direction) from any seed
326/// node, treating edges as undirected for the purpose of subgraph extraction.
327/// This is what `--job <name>` filtering uses: start from every Step in the
328/// requested job, then expand outward to capture every secret, identity,
329/// image, and artifact transitively connected to that job's authority surface.
330fn reachable_set(graph: &AuthorityGraph, seeds: &[NodeId]) -> HashSet<NodeId> {
331    let mut visited: HashSet<NodeId> = HashSet::new();
332    let mut queue: VecDeque<NodeId> = VecDeque::new();
333    for &s in seeds {
334        if visited.insert(s) {
335            queue.push_back(s);
336        }
337    }
338    while let Some(n) = queue.pop_front() {
339        for e in &graph.edges {
340            let next = if e.from == n {
341                Some(e.to)
342            } else if e.to == n {
343                Some(e.from)
344            } else {
345                None
346            };
347            if let Some(nx) = next {
348                if visited.insert(nx) {
349                    queue.push_back(nx);
350                }
351            }
352        }
353    }
354    visited
355}
356
357/// Render the authority graph as a Graphviz DOT digraph string.
358///
359/// When `filter_job` is `Some(name)`, restricts the output to the subgraph
360/// reachable (in either edge direction) from any Step node whose
361/// `META_JOB_NAME` metadata equals `name`. When `None`, includes every node
362/// and edge.
363///
364/// Output is deterministic — nodes and edges are emitted in their stored
365/// (insertion) order, which makes the result diff-friendly and testable.
366pub fn render_dot(graph: &AuthorityGraph, filter_job: Option<&str>) -> String {
367    let included: Option<HashSet<NodeId>> = match filter_job {
368        Some(name) => {
369            let seeds: Vec<NodeId> = graph
370                .nodes
371                .iter()
372                .filter(|n| {
373                    n.kind == NodeKind::Step
374                        && n.metadata.get(META_JOB_NAME).map(String::as_str) == Some(name)
375                })
376                .map(|n| n.id)
377                .collect();
378            Some(reachable_set(graph, &seeds))
379        }
380        None => None,
381    };
382
383    let mut out = String::new();
384    out.push_str("digraph taudit {\n");
385    out.push_str("    rankdir=LR;\n");
386    out.push_str("    node [fontname=\"Helvetica\"];\n");
387
388    for node in &graph.nodes {
389        if let Some(ref keep) = included {
390            if !keep.contains(&node.id) {
391                continue;
392            }
393        }
394        out.push_str(&format!(
395            "    \"n{}\" [label=\"{}\" shape={} color={}];\n",
396            node.id,
397            dot_escape(&node.name),
398            dot_shape(node.kind),
399            dot_color(node.trust_zone),
400        ));
401    }
402
403    for edge in &graph.edges {
404        if let Some(ref keep) = included {
405            if !keep.contains(&edge.from) || !keep.contains(&edge.to) {
406                continue;
407            }
408        }
409        out.push_str(&format!(
410            "    \"n{}\" -> \"n{}\" [label=\"{}\"];\n",
411            edge.from,
412            edge.to,
413            edge_label(edge.kind),
414        ));
415    }
416
417    out.push_str("}\n");
418    out
419}
420
421/// Distinct job names attached to Step nodes via `META_JOB_NAME`.
422/// Sorted alphabetically — used to render helpful error messages when a
423/// user passes `--job <name>` that doesn't match any step.
424pub fn job_names(graph: &AuthorityGraph) -> Vec<String> {
425    let mut names: Vec<String> = graph
426        .nodes
427        .iter()
428        .filter(|n| n.kind == NodeKind::Step)
429        .filter_map(|n| n.metadata.get(META_JOB_NAME).cloned())
430        .collect::<HashSet<_>>()
431        .into_iter()
432        .collect();
433    names.sort();
434    names
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::graph::*;
441
442    fn source(file: &str) -> PipelineSource {
443        PipelineSource {
444            file: file.into(),
445            repo: None,
446            git_ref: None,
447        }
448    }
449
450    #[test]
451    fn map_shows_step_access() {
452        let mut g = AuthorityGraph::new(source("ci.yml"));
453        let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
454        let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
455        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
456        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
457
458        g.add_edge(build, secret, EdgeKind::HasAccessTo);
459        g.add_edge(build, token, EdgeKind::HasAccessTo);
460        g.add_edge(deploy, token, EdgeKind::HasAccessTo);
461
462        let map = authority_map(&g);
463        assert_eq!(map.authorities.len(), 2);
464        assert_eq!(map.rows.len(), 2);
465
466        // build has access to both
467        let build_row = &map.rows[0];
468        assert!(build_row.access[0]); // API_KEY
469        assert!(build_row.access[1]); // GITHUB_TOKEN
470
471        // deploy has access to token only
472        let deploy_row = &map.rows[1];
473        assert!(!deploy_row.access[0]); // no API_KEY
474        assert!(deploy_row.access[1]); // GITHUB_TOKEN
475    }
476
477    #[test]
478    fn map_renders_table() {
479        let mut g = AuthorityGraph::new(source("ci.yml"));
480        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
481        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
482        g.add_edge(step, secret, EdgeKind::HasAccessTo);
483
484        let map = authority_map(&g);
485        let table = render_map(&map, 120);
486        assert!(table.contains("build"));
487        assert!(table.contains("KEY"));
488        assert!(table.contains('✓'));
489    }
490
491    #[test]
492    fn dot_output_contains_expected_node_and_edge() {
493        let mut g = AuthorityGraph::new(source("ci.yml"));
494        let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
495        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
496        g.add_edge(step, secret, EdgeKind::HasAccessTo);
497
498        let dot = render_dot(&g, None);
499        assert!(dot.starts_with("digraph taudit"), "dot output: {dot}");
500        // Node lines for both endpoints with their kind-driven shapes.
501        assert!(
502            dot.contains(&format!("\"n{step}\" [label=\"build\" shape=ellipse")),
503            "missing step node line in: {dot}"
504        );
505        assert!(
506            dot.contains(&format!("\"n{secret}\" [label=\"API_KEY\" shape=box")),
507            "missing secret node line in: {dot}"
508        );
509        // Edge line with snake-case label.
510        assert!(
511            dot.contains(&format!(
512                "\"n{step}\" -> \"n{secret}\" [label=\"has_access_to\"]"
513            )),
514            "missing edge line in: {dot}"
515        );
516    }
517
518    #[test]
519    fn job_filter_produces_subset_of_full_map() {
520        // Construct two jobs by hand: `build` (step accesses BUILD_SECRET) and
521        // `deploy` (step accesses DEPLOY_SECRET). With no filter all 4 nodes
522        // appear; filtering to `build` should drop the deploy step + its secret.
523        let mut g = AuthorityGraph::new(source("ci.yml"));
524
525        let build_secret = g.add_node(NodeKind::Secret, "BUILD_SECRET", TrustZone::FirstParty);
526        let mut build_meta = std::collections::HashMap::new();
527        build_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
528        let build_step =
529            g.add_node_with_metadata(NodeKind::Step, "compile", TrustZone::FirstParty, build_meta);
530        g.add_edge(build_step, build_secret, EdgeKind::HasAccessTo);
531
532        let deploy_secret = g.add_node(NodeKind::Secret, "DEPLOY_SECRET", TrustZone::FirstParty);
533        let mut deploy_meta = std::collections::HashMap::new();
534        deploy_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
535        let deploy_step =
536            g.add_node_with_metadata(NodeKind::Step, "ship", TrustZone::FirstParty, deploy_meta);
537        g.add_edge(deploy_step, deploy_secret, EdgeKind::HasAccessTo);
538
539        let full = render_dot(&g, None);
540        let filtered = render_dot(&g, Some("build"));
541
542        // Full output names every node; filtered output drops the deploy job.
543        assert!(full.contains("BUILD_SECRET") && full.contains("DEPLOY_SECRET"));
544        assert!(filtered.contains("BUILD_SECRET"));
545        assert!(
546            !filtered.contains("DEPLOY_SECRET"),
547            "deploy-job nodes leaked into build filter: {filtered}"
548        );
549        assert!(!filtered.contains("\"ship\""));
550
551        // Subset by line count: filtered must be strictly smaller than full.
552        let full_lines = full.lines().count();
553        let filtered_lines = filtered.lines().count();
554        assert!(
555            filtered_lines < full_lines,
556            "filtered DOT ({filtered_lines} lines) not smaller than full ({full_lines})"
557        );
558    }
559
560    #[test]
561    fn job_names_lists_distinct_jobs_sorted() {
562        let mut g = AuthorityGraph::new(source("ci.yml"));
563        let mut a_meta = std::collections::HashMap::new();
564        a_meta.insert(META_JOB_NAME.to_string(), "deploy".to_string());
565        g.add_node_with_metadata(NodeKind::Step, "s1", TrustZone::FirstParty, a_meta);
566        let mut b_meta = std::collections::HashMap::new();
567        b_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
568        g.add_node_with_metadata(NodeKind::Step, "s2", TrustZone::FirstParty, b_meta);
569        let mut c_meta = std::collections::HashMap::new();
570        c_meta.insert(META_JOB_NAME.to_string(), "build".to_string());
571        g.add_node_with_metadata(NodeKind::Step, "s3", TrustZone::FirstParty, c_meta);
572
573        let names = job_names(&g);
574        assert_eq!(names, vec!["build".to_string(), "deploy".to_string()]);
575    }
576}