Skip to main content

taudit_core/
map.rs

1use crate::graph::{AuthorityGraph, EdgeKind, NodeId, NodeKind, META_IDENTITY_SCOPE};
2
3/// A row in the authority map: one step and its authority grants.
4#[derive(Debug)]
5pub struct MapRow {
6    pub step_name: String,
7    pub trust_zone: String,
8    /// Index into the `authorities` Vec — true if this step has access.
9    pub access: Vec<bool>,
10}
11
12/// Authority map: which steps have access to which secrets/identities.
13#[derive(Debug)]
14pub struct AuthorityMap {
15    /// Column headers: authority source names (secrets + identities).
16    pub authorities: Vec<String>,
17    /// One row per step.
18    pub rows: Vec<MapRow>,
19}
20
21/// Build the authority map from a graph.
22pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
23    // Collect authority sources with all metadata needed for disambiguation.
24    // Two-pass approach: first gather raw data, then detect collisions and qualify.
25    struct RawAuthority {
26        id: NodeId,
27        name: String,
28        zone: String,
29        scope: Option<String>,
30    }
31
32    let raw: Vec<RawAuthority> = graph
33        .authority_sources()
34        .map(|n| RawAuthority {
35            id: n.id,
36            name: n.name.clone(),
37            zone: format!("{:?}", n.trust_zone),
38            scope: n.metadata.get(META_IDENTITY_SCOPE).cloned(),
39        })
40        .collect();
41
42    // Pass 1: count name occurrences to detect collisions.
43    let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
44    for r in &raw {
45        *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
46    }
47
48    // Pass 2: build display names, qualifying any that collide.
49    // Track (name, qualifier) occurrences so we can append numeric suffixes
50    // when the qualifier alone still collides.
51    let mut qualifier_counts: std::collections::HashMap<(String, String), usize> =
52        std::collections::HashMap::new();
53    for r in &raw {
54        if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
55            let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
56            *qualifier_counts
57                .entry((r.name.clone(), qualifier))
58                .or_insert(0) += 1;
59        }
60    }
61
62    let mut seen: std::collections::HashMap<(String, String), usize> =
63        std::collections::HashMap::new();
64    let authority_names: Vec<String> = raw
65        .iter()
66        .map(|r| {
67            if name_counts.get(r.name.as_str()).copied().unwrap_or(0) <= 1 {
68                // Unique name — no qualifier needed.
69                r.name.clone()
70            } else {
71                let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
72                let key = (r.name.clone(), qualifier.clone());
73                let total_with_qualifier = qualifier_counts.get(&key).copied().unwrap_or(1);
74                let idx = {
75                    let entry = seen.entry(key).or_insert(0);
76                    *entry += 1;
77                    *entry
78                };
79                if total_with_qualifier <= 1 {
80                    // Qualifier alone is sufficient.
81                    format!("{} ({})", r.name, qualifier)
82                } else {
83                    // Multiple share the same qualifier — append numeric index.
84                    format!("{} ({}#{})", r.name, qualifier, idx)
85                }
86            }
87        })
88        .collect();
89
90    // `authorities` keeps the original node name for internal lookup;
91    // `authority_names` holds the qualified display names used for rendering.
92    let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
93
94    // Build rows for each step
95    let mut rows = Vec::new();
96    for step in graph.nodes_of_kind(NodeKind::Step) {
97        let mut access = vec![false; authorities.len()];
98
99        for edge in graph.edges_from(step.id) {
100            if edge.kind != EdgeKind::HasAccessTo {
101                continue;
102            }
103            // Find which authority column this maps to
104            if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
105                access[idx] = true;
106            }
107        }
108
109        rows.push(MapRow {
110            step_name: step.name.clone(),
111            trust_zone: format!("{:?}", step.trust_zone),
112            access,
113        });
114    }
115
116    AuthorityMap {
117        authorities: authority_names,
118        rows,
119    }
120}
121
122/// Render the authority map as a formatted table string.
123pub fn render_map(map: &AuthorityMap) -> String {
124    if map.rows.is_empty() && map.authorities.is_empty() {
125        return "No steps or authority sources found.\n".to_string();
126    }
127
128    // Calculate column widths
129    let step_width = map
130        .rows
131        .iter()
132        .map(|r| r.step_name.len())
133        .max()
134        .unwrap_or(4)
135        .max(4);
136
137    let zone_width = map
138        .rows
139        .iter()
140        .map(|r| r.trust_zone.len())
141        .max()
142        .unwrap_or(4)
143        .max(4);
144
145    // Clamp authority column names to MAX_COL chars with ellipsis to prevent
146    // wide tables from wrapping. `chars().count()` handles Unicode correctly.
147    const MAX_COL: usize = 20;
148
149    let display_names: Vec<String> = map
150        .authorities
151        .iter()
152        .map(|a| {
153            let char_count = a.chars().count();
154            if char_count > MAX_COL {
155                let mut s: String = a.chars().take(MAX_COL - 1).collect();
156                s.push('…');
157                s
158            } else {
159                a.clone()
160            }
161        })
162        .collect();
163    let any_truncated = display_names
164        .iter()
165        .zip(map.authorities.iter())
166        .any(|(d, o)| d != o);
167
168    let auth_widths: Vec<usize> = display_names
169        .iter()
170        .map(|a| a.chars().count().max(3))
171        .collect();
172
173    let mut out = String::new();
174
175    // Header
176    out.push_str(&format!(
177        "{:<step_w$}  {:<zone_w$}",
178        "Step",
179        "Zone",
180        step_w = step_width,
181        zone_w = zone_width
182    ));
183    for (i, auth) in display_names.iter().enumerate() {
184        out.push_str(&format!("  {:^w$}", auth, w = auth_widths[i]));
185    }
186    out.push('\n');
187
188    // Separator
189    out.push_str(&"-".repeat(step_width));
190    out.push_str("  ");
191    out.push_str(&"-".repeat(zone_width));
192    for w in &auth_widths {
193        out.push_str("  ");
194        out.push_str(&"-".repeat(*w));
195    }
196    out.push('\n');
197
198    // Rows
199    for row in &map.rows {
200        out.push_str(&format!(
201            "{:<step_w$}  {:<zone_w$}",
202            row.step_name,
203            row.trust_zone,
204            step_w = step_width,
205            zone_w = zone_width
206        ));
207        for (i, &has) in row.access.iter().enumerate() {
208            let marker = if has { "X" } else { "." };
209            out.push_str(&format!("  {:^w$}", marker, w = auth_widths[i]));
210        }
211        out.push('\n');
212    }
213
214    if any_truncated {
215        out.push_str(&format!(
216            "\nNote: column names truncated to {MAX_COL} chars.\n"
217        ));
218    }
219
220    out
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::graph::*;
227
228    fn source(file: &str) -> PipelineSource {
229        PipelineSource {
230            file: file.into(),
231            repo: None,
232            git_ref: None,
233        }
234    }
235
236    #[test]
237    fn map_shows_step_access() {
238        let mut g = AuthorityGraph::new(source("ci.yml"));
239        let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
240        let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
241        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
242        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
243
244        g.add_edge(build, secret, EdgeKind::HasAccessTo);
245        g.add_edge(build, token, EdgeKind::HasAccessTo);
246        g.add_edge(deploy, token, EdgeKind::HasAccessTo);
247
248        let map = authority_map(&g);
249        assert_eq!(map.authorities.len(), 2);
250        assert_eq!(map.rows.len(), 2);
251
252        // build has access to both
253        let build_row = &map.rows[0];
254        assert!(build_row.access[0]); // API_KEY
255        assert!(build_row.access[1]); // GITHUB_TOKEN
256
257        // deploy has access to token only
258        let deploy_row = &map.rows[1];
259        assert!(!deploy_row.access[0]); // no API_KEY
260        assert!(deploy_row.access[1]); // GITHUB_TOKEN
261    }
262
263    #[test]
264    fn map_renders_table() {
265        let mut g = AuthorityGraph::new(source("ci.yml"));
266        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
267        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
268        g.add_edge(step, secret, EdgeKind::HasAccessTo);
269
270        let map = authority_map(&g);
271        let table = render_map(&map);
272        assert!(table.contains("build"));
273        assert!(table.contains("KEY"));
274        assert!(table.contains("X"));
275    }
276}