Skip to main content

taudit_report_terminal/
lib.rs

1use std::borrow::Cow;
2
3use colored::Colorize;
4use taudit_core::error::TauditError;
5use taudit_core::finding::{Finding, FindingSource, Recommendation, Severity};
6use taudit_core::graph::{AuthorityCompleteness, AuthorityGraph, EdgeKind, GapKind, NodeKind};
7use taudit_core::ports::ReportSink;
8
9/// Strip ASCII C0/C1 control characters (`\x00`-`\x1F`, `\x7F`-`\x9F`) and a
10/// small set of Unicode steering codepoints (RTL/LTR overrides, zero-width
11/// joiners, BOM) from `s`, EXCEPT for `\n` and `\t` which are required for
12/// legitimate multi-line / tabular terminal output.
13///
14/// This is the **render-boundary sanitiser** for the terminal sink. Attackers
15/// can plant escape-sequence payloads in pipeline YAML keys and custom-rule
16/// `name`/`id` fields; once those propagate into `finding.message`,
17/// `node.name`, `graph.source.file`, or completeness gap strings, a naive
18/// `writeln!("{}", attacker_string.bold())` lets the attacker:
19///   * clear the screen with `\x1b[2J\x1b[H` and impersonate a clean run,
20///   * wrap subsequent output in fake colour codes (`\x1b[1;32m...\x1b[0m`),
21///   * emit BEL (`\x07`) audio,
22///   * use RTL override (`\u{202e}`) to reverse glyph order,
23///   * inject zero-width joiner (`\u{200d}`) to defeat copy-paste review.
24///
25/// `colored::ColoredString` only WRAPS its input in CSI sequences — it does
26/// not sanitise the wrapped bytes. Callers MUST run this against any
27/// attacker-controllable string BEFORE handing it to `.bold()` /
28/// `.bright_black()` / `format!`.
29///
30/// **Performance:** O(n), single-pass. Returns `Cow::Borrowed` (zero-alloc)
31/// when the input is already clean; `Cow::Owned` otherwise.
32///
33/// **Hand-rolled, no new dependencies.**
34pub fn strip_control_chars(s: &str) -> Cow<'_, str> {
35    if !needs_control_strip(s) {
36        return Cow::Borrowed(s);
37    }
38    let mut out = String::with_capacity(s.len());
39    for c in s.chars() {
40        if is_disallowed_control(c) {
41            // Drop silently — replacing with a marker would itself be
42            // attacker-injectable noise. The threat we're defending is
43            // terminal interpretation; once the bytes are gone, the
44            // interpretation can't fire.
45            continue;
46        }
47        out.push(c);
48    }
49    Cow::Owned(out)
50}
51
52#[inline]
53fn is_disallowed_control(c: char) -> bool {
54    match c {
55        '\n' | '\t' => false,
56        // ASCII C0 (0x00..=0x1F) and DEL (0x7F).
57        '\x00'..='\x1F' | '\x7F' => true,
58        // C1 control range (0x80..=0x9F) — rarely seen in valid UTF-8 prose
59        // but legal codepoints; some terminals interpret them.
60        '\u{80}'..='\u{9F}' => true,
61        // Bidi / steering codepoints abused for spoofing.
62        '\u{200B}' // ZERO WIDTH SPACE
63        | '\u{200C}' // ZERO WIDTH NON-JOINER
64        | '\u{200D}' // ZERO WIDTH JOINER
65        | '\u{200E}' // LEFT-TO-RIGHT MARK
66        | '\u{200F}' // RIGHT-TO-LEFT MARK
67        | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING
68        | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING
69        | '\u{202C}' // POP DIRECTIONAL FORMATTING
70        | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE
71        | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE
72        | '\u{2066}' // LEFT-TO-RIGHT ISOLATE
73        | '\u{2067}' // RIGHT-TO-LEFT ISOLATE
74        | '\u{2068}' // FIRST STRONG ISOLATE
75        | '\u{2069}' // POP DIRECTIONAL ISOLATE
76        | '\u{FEFF}' // BOM / ZERO WIDTH NO-BREAK SPACE
77        => true,
78        _ => false,
79    }
80}
81
82#[inline]
83fn needs_control_strip(s: &str) -> bool {
84    s.chars().any(is_disallowed_control)
85}
86
87/// Sanitise an attacker-controllable string at the terminal render boundary
88/// and return an owned `String` ready to feed into `colored` or `format!`.
89/// Convenience wrapper used at the call sites where we need a `String` (e.g.
90/// `format!("[{label}]", label = clean(name))`).
91#[inline]
92fn clean(s: &str) -> String {
93    strip_control_chars(s).into_owned()
94}
95
96macro_rules! w {
97    ($w:expr, $($arg:tt)*) => {
98        write!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
99    };
100}
101
102macro_rules! wln {
103    ($w:expr) => {
104        writeln!($w).map_err(|e| TauditError::Report(e.to_string()))
105    };
106    ($w:expr, $($arg:tt)*) => {
107        writeln!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
108    };
109}
110
111const RULE_WIDTH: usize = 60;
112
113#[derive(Default)]
114pub struct TerminalReport {
115    pub verbose: bool,
116}
117
118impl<W: std::io::Write> ReportSink<W> for TerminalReport {
119    fn emit(
120        &self,
121        w: &mut W,
122        graph: &AuthorityGraph,
123        findings: &[Finding],
124    ) -> Result<(), TauditError> {
125        let is_partial = graph.completeness == AuthorityCompleteness::Partial
126            || graph.completeness == AuthorityCompleteness::Unknown;
127
128        // ── File section header ──────────────────────────────────
129        // SECURITY: `graph.source.file` is attacker-controllable (a hostile
130        // PR author can rename a workflow file). Strip control characters at
131        // the render boundary so a filename like
132        // `\x1b[2J\x1b[Hci.yml` cannot clear the screen and impersonate a
133        // fresh run. Sister sinks (JSON / SARIF) ship the raw filename — only
134        // the terminal renderer interprets escape bytes, so only the terminal
135        // renderer sanitises. See `strip_control_chars` doc-comment.
136        let source_file_clean = clean(&graph.source.file);
137        wln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
138        wln!(
139            w,
140            "{}",
141            format!("Authority Graph: {source_file_clean}")
142                .bright_white()
143                .bold()
144        )?;
145
146        let steps = graph.nodes_of_kind(NodeKind::Step).count();
147        let secrets = graph.nodes_of_kind(NodeKind::Secret).count();
148        let images = graph.nodes_of_kind(NodeKind::Image).count();
149        let identities = graph.nodes_of_kind(NodeKind::Identity).count();
150        wln!(
151            w,
152            "{}",
153            format!(
154                "  Steps: {steps} | Secrets: {secrets} | Actions: {images} | Identities: {identities}"
155            )
156            .bright_black()
157        )?;
158
159        // ── Partial graph warning ────────────────────────────────
160        if is_partial {
161            wln!(w)?;
162            match graph.completeness {
163                AuthorityCompleteness::Partial => {
164                    let header_prefix = match graph.worst_gap_kind() {
165                        Some(GapKind::Opaque) => "error: ⛔".red().bold().to_string(),
166                        Some(GapKind::Expression) => "note: ·".dimmed().to_string(),
167                        // Structural or None
168                        _ => "note: ⚠".bright_yellow().bold().to_string(),
169                    };
170                    wln!(
171                        w,
172                        "  {} partial graph — findings below tagged {}",
173                        header_prefix,
174                        "[partial]".yellow().dimmed()
175                    )?;
176                    for (kind, gap) in graph
177                        .completeness_gap_kinds
178                        .iter()
179                        .zip(graph.completeness_gaps.iter())
180                    {
181                        let kind_label = match kind {
182                            GapKind::Opaque => "[opaque]".red().to_string(),
183                            GapKind::Structural => "[structural]".yellow().to_string(),
184                            GapKind::Expression => "[expression]".dimmed().to_string(),
185                        };
186                        // SECURITY: gap strings are derived from parser output
187                        // and can include attacker-controlled YAML keys
188                        // (composite-action names, expression text). Strip
189                        // control chars before colouring.
190                        let gap_clean = clean(gap);
191                        wln!(w, "    {} {}", kind_label, gap_clean.dimmed())?;
192                    }
193                    // Fallback: if gap_kinds is shorter than gaps, print remaining gaps
194                    // without prefix (defensive — keeps behaviour graceful if invariants slip).
195                    for gap in graph
196                        .completeness_gaps
197                        .iter()
198                        .skip(graph.completeness_gap_kinds.len())
199                    {
200                        let gap_clean = clean(gap);
201                        wln!(w, "    {}", format!("- {gap_clean}").yellow())?;
202                    }
203                }
204                AuthorityCompleteness::Unknown => {
205                    wln!(
206                        w,
207                        "  {} completeness unknown — treat as partial",
208                        "note: ⚠".bright_yellow().bold()
209                    )?;
210                }
211                AuthorityCompleteness::Complete => {}
212            }
213        }
214
215        // ── Clean file ───────────────────────────────────────────
216        if findings.is_empty() {
217            wln!(w, "\n  {}", "✓ no findings".green().bold())?;
218            return Ok(());
219        }
220
221        // ── Findings ─────────────────────────────────────────────
222        wln!(w)?;
223        for finding in findings {
224            let sev_tag = severity_tag(finding.severity);
225            // Default-quiet: per-finding [partial] tags add a lot of inline
226            // noise on long runs where every file has Expression/Structural
227            // gaps (the common case). Suppress them unless --verbose, with one
228            // hard exception: when the worst gap is `Opaque`, the graph is
229            // effectively unusable and we always surface `[partial:opaque]`
230            // inline so an operator can't miss it. The header warning and the
231            // run-summary footer are unaffected — they remain always-on.
232            let partial_tag = if is_partial {
233                let always_show = graph.worst_gap_kind() == Some(GapKind::Opaque);
234                if self.verbose || always_show {
235                    if always_show && !self.verbose {
236                        format!(" {}", "[partial:opaque]".red().bold())
237                    } else {
238                        format!(" {}", "[partial]".yellow().dimmed())
239                    }
240                } else {
241                    // Suppressed by default for Expression/Structural gaps.
242                    String::new()
243                }
244            } else {
245                String::new()
246            };
247
248            // Custom-rule provenance prefix: surface the originating YAML
249            // file path so an operator scanning the terminal output can tell
250            // an authentic built-in finding from a planted custom invariant
251            // without re-running with --format json. Built-in findings get
252            // no prefix to keep the common path uncluttered.
253            // SECURITY: `source_file` is the path to an attacker-controlled
254            // YAML; even the path basename can be crafted to contain ANSI
255            // sequences. Sanitise before rendering.
256            let custom_tag = match &finding.source {
257                FindingSource::Custom { source_file } => {
258                    let label = if source_file.as_os_str().is_empty() {
259                        "custom".to_string()
260                    } else {
261                        // Show the file name only — full path noise overwhelms
262                        // terminal width. JSON / SARIF carry the absolute path.
263                        let name = source_file
264                            .file_name()
265                            .and_then(|s| s.to_str())
266                            .unwrap_or_else(|| source_file.to_str().unwrap_or("custom"));
267                        format!("custom: {}", clean(name))
268                    };
269                    format!(" {}", format!("[{label}]").magenta().dimmed())
270                }
271                FindingSource::BuiltIn => String::new(),
272            };
273
274            // SECURITY: `finding.message` is the most attacker-reachable
275            // field. Custom-rule YAML composes the message as
276            // `format!("[{id}] {name}: {nodename}")` — every component is
277            // attacker-controllable. Strip control chars before bolding so a
278            // crafted message like
279            // `"\x1b[2J\x1b[H[1;32m✓ no findings\x1b[0m"`
280            // cannot clear the screen and impersonate a clean run.
281            let message_clean = clean(&finding.message);
282            wln!(
283                w,
284                "{}{}{} {}",
285                sev_tag,
286                partial_tag,
287                custom_tag,
288                message_clean.bold()
289            )?;
290
291            // Propagation path
292            // SECURITY: every `node.name` originates from YAML keys (step
293            // names, secret names, environment names). All are
294            // attacker-controllable. Strip control chars before colouring.
295            if let Some(ref path) = finding.path {
296                let source_name_owned = graph
297                    .node(path.source)
298                    .map(|n| clean(&n.name))
299                    .unwrap_or_else(|| "?".to_string());
300                let source_kind = graph
301                    .node(path.source)
302                    .map(|n| node_kind_label(n.kind))
303                    .unwrap_or("");
304
305                if path.edges.len() <= 2 {
306                    // Short path — inline
307                    w!(
308                        w,
309                        "  {} {} {}",
310                        "Path:".bright_black(),
311                        source_name_owned.bright_white(),
312                        format!("({source_kind})").bright_black()
313                    )?;
314                    for edge_id in &path.edges {
315                        if let Some(edge) = graph.edge(*edge_id) {
316                            let target = graph.node(edge.to);
317                            let name = target
318                                .map(|n| clean(&n.name))
319                                .unwrap_or_else(|| "?".to_string());
320                            let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
321                            w!(
322                                w,
323                                " {} {} {}",
324                                "→".bright_black(),
325                                name.bright_white(),
326                                format!("({kind})").bright_black()
327                            )?;
328                        }
329                    }
330                    wln!(w)?;
331                } else {
332                    // Long path — vertical
333                    wln!(w, "  {}:", "Path".bright_black())?;
334                    wln!(
335                        w,
336                        "      {} {}",
337                        source_name_owned.bright_white(),
338                        format!("({source_kind})").bright_black()
339                    )?;
340                    for edge_id in &path.edges {
341                        if let Some(edge) = graph.edge(*edge_id) {
342                            let target = graph.node(edge.to);
343                            let name = target
344                                .map(|n| clean(&n.name))
345                                .unwrap_or_else(|| "?".to_string());
346                            let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
347                            wln!(
348                                w,
349                                "    {} {} {}",
350                                "→".bright_black(),
351                                name.bright_white(),
352                                format!("({kind})").bright_black()
353                            )?;
354                        }
355                    }
356                }
357
358                if self.verbose {
359                    let mut path_nodes = vec![path.source];
360                    for edge_id in &path.edges {
361                        if let Some(edge) = graph.edge(*edge_id) {
362                            path_nodes.push(edge.to);
363                        }
364                    }
365                    emit_verbose_nodes(w, graph, &path_nodes)?;
366                }
367            } else if !finding.nodes_involved.is_empty() {
368                // No propagation path — show involved nodes.
369                // SECURITY: as above, sanitise each `node.name` before
370                // wrapping in `colored` styling.
371                let nodes = &finding.nodes_involved;
372                let display: Vec<String> = nodes
373                    .iter()
374                    .take(4)
375                    .filter_map(|&id| graph.node(id))
376                    .map(|n| {
377                        format!(
378                            "{} {}",
379                            clean(&n.name).bright_white(),
380                            format!("({})", node_kind_label(n.kind)).bright_black()
381                        )
382                    })
383                    .collect();
384
385                let suffix = if nodes.len() > 4 {
386                    format!(
387                        " {}",
388                        format!("…(+{} more)", nodes.len() - 4).bright_black()
389                    )
390                } else {
391                    String::new()
392                };
393
394                // Use the appropriate connector based on edge semantics
395                let connector = if finding.nodes_involved.windows(2).any(|w| {
396                    graph
397                        .edges_from(w[0])
398                        .any(|e| e.to == w[1] && e.kind == EdgeKind::PersistsTo)
399                }) {
400                    format!(" {} ", "persists→".bright_black())
401                } else {
402                    format!(" {} ", "→".bright_black())
403                };
404
405                wln!(
406                    w,
407                    "  {} {}{}",
408                    "Nodes:".bright_black(),
409                    display.join(&connector),
410                    suffix
411                )?;
412
413                if self.verbose {
414                    emit_verbose_nodes(w, graph, nodes)?;
415                }
416            }
417
418            // Recommendation
419            // SECURITY: `Recommendation::Manual { action }` is sourced from
420            // custom-rule YAML `description:` (see `evaluate_custom_rules` in
421            // `taudit-core/src/custom_rules.rs`). Sanitise before colouring.
422            let rec = clean(&format_recommendation(&finding.recommendation));
423            wln!(w, "  {} {}", "Recommendation:".green().bold(), rec.green())?;
424            wln!(w)?;
425        }
426
427        Ok(())
428    }
429}
430
431fn severity_tag(sev: Severity) -> String {
432    match sev {
433        Severity::Critical => format!("[{}]", "CRITICAL".bright_red().bold().reversed()),
434        Severity::High => format!("[{}]", "HIGH".bright_red().bold()),
435        Severity::Medium => format!("[{}]", "MEDIUM".yellow().bold()),
436        Severity::Low => format!("[{}]", "LOW".bright_yellow()),
437        Severity::Info => format!("[{}]", "INFO".bright_cyan()),
438    }
439}
440
441fn node_kind_label(kind: NodeKind) -> &'static str {
442    match kind {
443        NodeKind::Step => "step",
444        NodeKind::Secret => "secret",
445        NodeKind::Identity => "identity",
446        NodeKind::Artifact => "artifact",
447        NodeKind::Image => "action/image",
448    }
449}
450
451fn format_recommendation(rec: &Recommendation) -> String {
452    match rec {
453        Recommendation::TsafeRemediation { command, .. } => command.clone(),
454        Recommendation::CellosRemediation { spec_hint, .. } => spec_hint.clone(),
455        Recommendation::PinAction { pinned, .. } => format!("Pin to {pinned}"),
456        Recommendation::ReducePermissions { minimum, .. } => {
457            format!("Reduce permissions to {minimum}")
458        }
459        Recommendation::FederateIdentity { oidc_provider, .. } => {
460            format!("Replace with {oidc_provider} OIDC")
461        }
462        Recommendation::Manual { action } => action.clone(),
463    }
464}
465
466fn emit_verbose_nodes<W: std::io::Write>(
467    w: &mut W,
468    graph: &AuthorityGraph,
469    node_ids: &[usize],
470) -> Result<(), TauditError> {
471    // SECURITY: every metadata value (`scope`, `permissions`, `digest`) is
472    // attacker-controllable — `permissions:` is a top-level YAML map under
473    // operator control of the pipeline definition, `digest` comes from
474    // `image@sha256:…` text which can be padded with control bytes before
475    // the `@`, and `identity_scope` is a free-form string. Strip control
476    // chars at the render boundary so a crafted `permissions:` value cannot
477    // smuggle ANSI past the verbose-mode renderer.
478    for &id in node_ids {
479        if let Some(node) = graph.node(id) {
480            let kind = node_kind_label(node.kind);
481            let zone = format!("{:?}", node.trust_zone).to_lowercase();
482            let name_clean = clean(&node.name);
483            w!(w, "    {} ({kind}, {zone})", name_clean.bright_black())?;
484            if let Some(scope) = node.metadata.get("identity_scope") {
485                w!(w, ", scope: {}", clean(scope))?;
486            }
487            if let Some(perms) = node.metadata.get("permissions") {
488                w!(w, ", permissions: {}", clean(perms))?;
489            }
490            if let Some(digest) = node.metadata.get("digest") {
491                let digest_clean = clean(digest);
492                w!(w, ", pin: {}…", &digest_clean[..digest_clean.len().min(12)])?;
493            }
494            if node
495                .metadata
496                .get("inferred")
497                .map(|v| v == "true")
498                .unwrap_or(false)
499            {
500                w!(w, " (inferred)")?;
501            }
502            wln!(w)?;
503        }
504    }
505    Ok(())
506}
507
508/// Print the run-level banner (call once before the scan loop).
509pub fn print_banner<W: std::io::Write>(w: &mut W, file_count: usize) -> std::io::Result<()> {
510    writeln!(
511        w,
512        "{}",
513        format!(
514            "taudit {} — {} {}",
515            env!("CARGO_PKG_VERSION"),
516            file_count,
517            if file_count == 1 { "file" } else { "files" }
518        )
519        .bright_white()
520        .bold()
521    )
522}
523
524/// Counts for the run-level summary footer.
525pub struct RunSummary {
526    pub total_files: usize,
527    pub files_with_findings: usize,
528    pub clean_files: usize,
529    pub partial_files: usize,
530    pub critical: usize,
531    pub high: usize,
532    pub medium: usize,
533    pub low: usize,
534}
535
536/// Print the run-level summary (call once after the scan loop).
537pub fn print_summary<W: std::io::Write>(w: &mut W, s: &RunSummary) -> std::io::Result<()> {
538    writeln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
539
540    if s.clean_files > 0 {
541        writeln!(
542            w,
543            "{}",
544            format!(
545                "✓ {} {} clean",
546                s.clean_files,
547                if s.clean_files == 1 { "file" } else { "files" }
548            )
549            .green()
550            .bold()
551        )?;
552    }
553
554    let total_findings = s.critical + s.high + s.medium + s.low;
555    if total_findings == 0 {
556        writeln!(w, "{}", "✓ no findings across all files".green().bold())?;
557        return Ok(());
558    }
559
560    write!(w, "{} ", "Summary".bright_white().bold())?;
561    let mut parts = Vec::new();
562    if s.critical > 0 {
563        parts.push(format!(
564            "{}",
565            format!("{} critical", s.critical).bright_red().bold()
566        ));
567    }
568    if s.high > 0 {
569        parts.push(format!("{}", format!("{} high", s.high).bright_red()));
570    }
571    if s.medium > 0 {
572        parts.push(format!("{}", format!("{} medium", s.medium).yellow()));
573    }
574    if s.low > 0 {
575        parts.push(format!("{}", format!("{} low", s.low).bright_yellow()));
576    }
577    writeln!(w, "{}", parts.join("  "))?;
578
579    writeln!(
580        w,
581        "{}",
582        format!(
583            "  Files with findings: {} / {}",
584            s.files_with_findings, s.total_files
585        )
586        .bright_black()
587    )?;
588
589    if s.partial_files > 0 {
590        writeln!(
591            w,
592            "{}",
593            format!(
594                "  Partial graphs: {} — findings from partial graphs may be incomplete",
595                s.partial_files
596            )
597            .yellow()
598        )?;
599    }
600
601    Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use taudit_core::finding::{
608        Finding, FindingCategory, FindingExtras, FindingSource, Recommendation, Severity,
609    };
610    use taudit_core::graph::{GapKind, PipelineSource};
611    use taudit_core::ports::ReportSink;
612
613    // ── strip_control_chars unit tests ─────────────────────────────
614
615    #[test]
616    fn strip_control_chars_passes_clean_text_unchanged() {
617        let clean = "AWS_KEY reaches deploy";
618        let out = strip_control_chars(clean);
619        assert!(
620            matches!(out, Cow::Borrowed(_)),
621            "clean input must zero-alloc"
622        );
623        assert_eq!(out, clean);
624    }
625
626    #[test]
627    fn strip_control_chars_preserves_newline_and_tab() {
628        let s = "line1\nline2\tcol";
629        let out = strip_control_chars(s);
630        assert!(matches!(out, Cow::Borrowed(_)));
631        assert_eq!(out, s);
632    }
633
634    #[test]
635    fn strip_control_chars_drops_esc_and_clear_screen() {
636        // The headline attack: clear-screen + cursor-home.
637        let hostile = "\x1b[2J\x1b[Hfake clean output\x1b[0m";
638        let out = strip_control_chars(hostile);
639        assert!(!out.contains('\x1b'), "ESC byte must be stripped");
640        assert_eq!(out, "[2J[Hfake clean output[0m");
641    }
642
643    #[test]
644    fn strip_control_chars_drops_bel_and_del() {
645        let hostile = "ding\x07then\x7Fdel";
646        let out = strip_control_chars(hostile);
647        assert!(!out.bytes().any(|b| b == 0x07));
648        assert!(!out.bytes().any(|b| b == 0x7F));
649        assert_eq!(out, "dingthendel");
650    }
651
652    #[test]
653    fn strip_control_chars_drops_rtl_and_zwj() {
654        let hostile = "user\u{202E}name\u{200D}joiner";
655        let out = strip_control_chars(hostile);
656        assert!(!out.contains('\u{202E}'));
657        assert!(!out.contains('\u{200D}'));
658        assert_eq!(out, "usernamejoiner");
659    }
660
661    #[test]
662    fn strip_control_chars_preserves_emoji_and_unicode_prose() {
663        let s = "✓ no findings — Authority Graph: ci.yml";
664        let out = strip_control_chars(s);
665        assert!(matches!(out, Cow::Borrowed(_)));
666        assert_eq!(out, s);
667    }
668
669    #[test]
670    fn strip_control_chars_drops_c1_range() {
671        // 0x80..=0x9F is the C1 control range. Some terminals interpret it.
672        let hostile = "before\u{0080}\u{009F}after";
673        let out = strip_control_chars(hostile);
674        assert_eq!(out, "beforeafter");
675    }
676
677    /// Build a fresh graph for tests. Single mutex-free entry point — keeps each
678    /// test self-contained.
679    fn test_graph() -> AuthorityGraph {
680        AuthorityGraph::new(PipelineSource {
681            file: "test.yml".into(),
682            repo: None,
683            git_ref: None,
684            commit_sha: None,
685        })
686    }
687
688    /// Render a graph with no findings and return the raw string. Disables ANSI
689    /// colour codes so substring assertions stay deterministic.
690    fn render(graph: &AuthorityGraph) -> String {
691        // Force colour off so assertions can compare against plain text.
692        // Other tests in this binary may run in parallel and re-enable colour;
693        // since we set BEFORE rendering and the override is process-global, the
694        // only safe pattern across crates is to drop ANSI from the output.
695        // We do both: set_override(false) AND strip any residual escapes.
696        colored::control::set_override(false);
697        let reporter = TerminalReport { verbose: false };
698        let mut buf: Vec<u8> = Vec::new();
699        reporter
700            .emit(&mut buf, graph, &[])
701            .expect("emit should succeed");
702        let raw = String::from_utf8(buf).expect("utf-8 output");
703        strip_ansi(&raw)
704    }
705
706    /// Strip ANSI CSI escape sequences (`ESC [ ... <final-byte> `) while
707    /// preserving multi-byte UTF-8 (⛔, ⚠, ·, →). Iterates over chars, not
708    /// bytes, so glyphs survive intact when colour override happens to be
709    /// re-enabled by a neighbouring test sharing this process.
710    fn strip_ansi(s: &str) -> String {
711        let mut out = String::with_capacity(s.len());
712        let mut chars = s.chars().peekable();
713        while let Some(c) = chars.next() {
714            if c == '\u{1B}' && chars.peek() == Some(&'[') {
715                chars.next(); // consume '['
716                              // CSI runs until a final byte in 0x40..=0x7E.
717                for fc in chars.by_ref() {
718                    let cp = fc as u32;
719                    if (0x40..=0x7E).contains(&cp) {
720                        break;
721                    }
722                }
723            } else {
724                out.push(c);
725            }
726        }
727        out
728    }
729
730    #[test]
731    fn opaque_gap_header_shows_error_prefix() {
732        let mut g = test_graph();
733        g.mark_partial(GapKind::Opaque, "zero steps");
734        let out = render(&g);
735        assert!(
736            out.contains("error: ⛔"),
737            "expected opaque header 'error: ⛔', got:\n{out}"
738        );
739        assert!(
740            out.contains("[opaque]"),
741            "expected [opaque] gap label, got:\n{out}"
742        );
743    }
744
745    #[test]
746    fn structural_gap_header_shows_warning_prefix() {
747        let mut g = test_graph();
748        g.mark_partial(GapKind::Structural, "composite unresolved");
749        let out = render(&g);
750        assert!(
751            out.contains("note: ⚠"),
752            "expected structural header 'note: ⚠', got:\n{out}"
753        );
754        assert!(
755            out.contains("[structural]"),
756            "expected [structural] gap label, got:\n{out}"
757        );
758    }
759
760    #[test]
761    fn expression_gap_header_shows_note_prefix() {
762        let mut g = test_graph();
763        g.mark_partial(GapKind::Expression, "matrix hides paths");
764        let out = render(&g);
765        assert!(
766            out.contains("note: ·"),
767            "expected expression header 'note: ·', got:\n{out}"
768        );
769        assert!(
770            out.contains("[expression]"),
771            "expected [expression] gap label, got:\n{out}"
772        );
773    }
774
775    /// Minimal finding for verbosity tests. Severity Medium so the rendered
776    /// line contains `[MEDIUM]` — used as a neighbour in substring assertions
777    /// to be sure we're inspecting the per-finding line, not the header.
778    fn test_finding() -> Finding {
779        Finding {
780            severity: Severity::Medium,
781            category: FindingCategory::UnpinnedAction,
782            path: None,
783            nodes_involved: vec![],
784            message: "test finding for verbosity gating".into(),
785            recommendation: Recommendation::Manual {
786                action: "fix".into(),
787            },
788            source: FindingSource::BuiltIn,
789            extras: FindingExtras::default(),
790        }
791    }
792
793    /// Render a graph + findings at the requested verbosity. Mirrors the
794    /// no-findings `render` helper above, but lets each verbosity test pin
795    /// the `verbose` flag explicitly and supply its own findings vector.
796    fn render_with(graph: &AuthorityGraph, findings: &[Finding], verbose: bool) -> String {
797        colored::control::set_override(false);
798        let reporter = TerminalReport { verbose };
799        let mut buf: Vec<u8> = Vec::new();
800        reporter
801            .emit(&mut buf, graph, findings)
802            .expect("emit should succeed");
803        let raw = String::from_utf8(buf).expect("utf-8 output");
804        strip_ansi(&raw)
805    }
806
807    #[test]
808    fn default_quiet_structural_gap_suppresses_inline_tag() {
809        let mut g = test_graph();
810        g.mark_partial(GapKind::Structural, "composite unresolved");
811        let findings = vec![test_finding()];
812        let out = render_with(&g, &findings, false);
813
814        // Header warning stays always-on — confirms we didn't break Phase 1.
815        assert!(
816            out.contains("note: ⚠"),
817            "expected structural header 'note: ⚠', got:\n{out}"
818        );
819        // The per-finding inline tag must be suppressed in default-quiet mode
820        // for Structural gaps. Anchor the search to the finding line itself
821        // (after `[MEDIUM]`) so the `[partial]` substring inside the header
822        // hint can't false-positive this check.
823        let finding_line = out
824            .lines()
825            .find(|l| l.contains("[MEDIUM]"))
826            .expect("expected a finding line containing [MEDIUM]");
827        assert!(
828            !finding_line.contains("[partial]"),
829            "default-quiet should suppress inline [partial] for Structural, \
830             but finding line had it: {finding_line}"
831        );
832        assert!(
833            !finding_line.contains("[partial:opaque]"),
834            "Structural gap must not render [partial:opaque]: {finding_line}"
835        );
836    }
837
838    #[test]
839    fn default_quiet_opaque_gap_shows_inline_tag() {
840        let mut g = test_graph();
841        g.mark_partial(GapKind::Opaque, "zero steps");
842        let findings = vec![test_finding()];
843        let out = render_with(&g, &findings, false);
844
845        // Header still shows the opaque error prefix.
846        assert!(
847            out.contains("error: ⛔"),
848            "expected opaque header 'error: ⛔', got:\n{out}"
849        );
850        // Opaque gaps override the default-quiet suppression: the inline
851        // `[partial:opaque]` tag must appear on the finding line so a total
852        // graph failure can't slip past triage.
853        let finding_line = out
854            .lines()
855            .find(|l| l.contains("[MEDIUM]"))
856            .expect("expected a finding line containing [MEDIUM]");
857        assert!(
858            finding_line.contains("[partial:opaque]"),
859            "Opaque gap must render inline [partial:opaque] even in quiet mode: {finding_line}"
860        );
861    }
862
863    #[test]
864    fn verbose_structural_gap_shows_inline_tag() {
865        let mut g = test_graph();
866        g.mark_partial(GapKind::Structural, "composite unresolved");
867        let findings = vec![test_finding()];
868        let out = render_with(&g, &findings, true);
869
870        // With --verbose the legacy `[partial]` tag returns for non-opaque
871        // gaps. Confirm it lands on the finding line, not just the header.
872        let finding_line = out
873            .lines()
874            .find(|l| l.contains("[MEDIUM]"))
875            .expect("expected a finding line containing [MEDIUM]");
876        assert!(
877            finding_line.contains("[partial]"),
878            "verbose should render inline [partial] for Structural gap: {finding_line}"
879        );
880        // Make sure we didn't accidentally promote a structural gap to opaque
881        // styling under --verbose.
882        assert!(
883            !finding_line.contains("[partial:opaque]"),
884            "Structural gap must not render [partial:opaque] under --verbose: {finding_line}"
885        );
886    }
887
888    /// Mirror of `taudit-report-json::tests::json_output_is_byte_deterministic_across_runs`.
889    /// The terminal renderer walks the same `AuthorityGraph` HashMaps the JSON
890    /// sink does (node metadata, edge endpoints, ordered findings) so any leak
891    /// of HashMap order would show up as text-line shuffling between runs.
892    /// Force `colored::control::set_override(false)` to drop ANSI (text colour
893    /// is process-global state — see `render` above), then emit 9× and assert
894    /// every byte matches.
895    #[test]
896    fn terminal_output_is_byte_deterministic_across_runs() {
897        use std::collections::HashMap;
898        use taudit_core::graph::{EdgeKind, NodeKind, TrustZone};
899
900        fn build_graph() -> (AuthorityGraph, Vec<Finding>) {
901            let mut graph = AuthorityGraph::new(PipelineSource {
902                file: "ci.yml".into(),
903                repo: None,
904                git_ref: None,
905                commit_sha: None,
906            });
907            let secret_a = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
908            let secret_b = graph.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
909            let step = graph.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
910            graph.add_edge(step, secret_a, EdgeKind::HasAccessTo);
911            graph.add_edge(step, secret_b, EdgeKind::HasAccessTo);
912            if let Some(node) = graph.nodes.get_mut(step) {
913                let mut meta: HashMap<String, String> = HashMap::new();
914                meta.insert("z_field".into(), "z".into());
915                meta.insert("a_field".into(), "a".into());
916                meta.insert("m_field".into(), "m".into());
917                meta.insert("k_field".into(), "k".into());
918                meta.insert("c_field".into(), "c".into());
919                node.metadata = meta;
920            }
921            graph
922                .metadata
923                .insert("trigger".into(), "pull_request".into());
924            graph.metadata.insert("platform".into(), "github".into());
925            let findings = vec![Finding {
926                severity: Severity::High,
927                category: FindingCategory::AuthorityPropagation,
928                path: None,
929                nodes_involved: vec![secret_a, step],
930                message: "AWS_KEY reaches deploy".into(),
931                recommendation: Recommendation::Manual {
932                    action: "scope it".into(),
933                },
934                source: FindingSource::BuiltIn,
935                extras: FindingExtras::default(),
936            }];
937            (graph, findings)
938        }
939
940        // ANSI escape codes depend on a process-global flag — pin it off so
941        // a parallel test that flips it can't smuggle non-determinism in.
942        colored::control::set_override(false);
943
944        let mut runs: Vec<Vec<u8>> = Vec::with_capacity(9);
945        for _ in 0..9 {
946            let (g, f) = build_graph();
947            let mut buf: Vec<u8> = Vec::new();
948            TerminalReport { verbose: false }
949                .emit(&mut buf, &g, &f)
950                .expect("emit should succeed");
951            runs.push(buf);
952        }
953
954        let first = &runs[0];
955        for (i, run) in runs.iter().enumerate().skip(1) {
956            assert_eq!(
957                first, run,
958                "run 0 and run {i} produced byte-different terminal output (non-determinism regression)"
959            );
960        }
961    }
962}