use std::borrow::Cow;
use colored::Colorize;
use taudit_core::error::TauditError;
use taudit_core::finding::{Finding, FindingSource, Recommendation, Severity};
use taudit_core::graph::{AuthorityCompleteness, AuthorityGraph, EdgeKind, GapKind, NodeKind};
use taudit_core::ports::ReportSink;
pub fn strip_control_chars(s: &str) -> Cow<'_, str> {
if !needs_control_strip(s) {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if is_disallowed_control(c) {
continue;
}
out.push(c);
}
Cow::Owned(out)
}
#[inline]
fn is_disallowed_control(c: char) -> bool {
match c {
'\n' | '\t' => false,
'\x00'..='\x1F' | '\x7F' => true,
'\u{80}'..='\u{9F}' => true,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' | '\u{FEFF}' => true,
_ => false,
}
}
#[inline]
fn needs_control_strip(s: &str) -> bool {
s.chars().any(is_disallowed_control)
}
#[inline]
fn clean(s: &str) -> String {
strip_control_chars(s).into_owned()
}
macro_rules! w {
($w:expr, $($arg:tt)*) => {
write!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
};
}
macro_rules! wln {
($w:expr) => {
writeln!($w).map_err(|e| TauditError::Report(e.to_string()))
};
($w:expr, $($arg:tt)*) => {
writeln!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
};
}
const RULE_WIDTH: usize = 60;
#[derive(Default)]
pub struct TerminalReport {
pub verbose: bool,
}
impl<W: std::io::Write> ReportSink<W> for TerminalReport {
fn emit(
&self,
w: &mut W,
graph: &AuthorityGraph,
findings: &[Finding],
) -> Result<(), TauditError> {
let is_partial = graph.completeness == AuthorityCompleteness::Partial
|| graph.completeness == AuthorityCompleteness::Unknown;
let source_file_clean = clean(&graph.source.file);
wln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
wln!(
w,
"{}",
format!("Authority Graph: {source_file_clean}")
.bright_white()
.bold()
)?;
let steps = graph.nodes_of_kind(NodeKind::Step).count();
let secrets = graph.nodes_of_kind(NodeKind::Secret).count();
let images = graph.nodes_of_kind(NodeKind::Image).count();
let identities = graph.nodes_of_kind(NodeKind::Identity).count();
wln!(
w,
"{}",
format!(
" Steps: {steps} | Secrets: {secrets} | Actions: {images} | Identities: {identities}"
)
.bright_black()
)?;
if is_partial {
wln!(w)?;
match graph.completeness {
AuthorityCompleteness::Partial => {
let header_prefix = match graph.worst_gap_kind() {
Some(GapKind::Opaque) => "error: ⛔".red().bold().to_string(),
Some(GapKind::Expression) => "note: ·".dimmed().to_string(),
_ => "note: ⚠".bright_yellow().bold().to_string(),
};
wln!(
w,
" {} partial graph — findings below tagged {}",
header_prefix,
"[partial]".yellow().dimmed()
)?;
for (kind, gap) in graph
.completeness_gap_kinds
.iter()
.zip(graph.completeness_gaps.iter())
{
let kind_label = match kind {
GapKind::Opaque => "[opaque]".red().to_string(),
GapKind::Structural => "[structural]".yellow().to_string(),
GapKind::Expression => "[expression]".dimmed().to_string(),
};
let gap_clean = clean(gap);
wln!(w, " {} {}", kind_label, gap_clean.dimmed())?;
}
for gap in graph
.completeness_gaps
.iter()
.skip(graph.completeness_gap_kinds.len())
{
let gap_clean = clean(gap);
wln!(w, " {}", format!("- {gap_clean}").yellow())?;
}
}
AuthorityCompleteness::Unknown => {
wln!(
w,
" {} completeness unknown — treat as partial",
"note: ⚠".bright_yellow().bold()
)?;
}
AuthorityCompleteness::Complete => {}
}
}
if findings.is_empty() {
wln!(w, "\n {}", "✓ no findings".green().bold())?;
return Ok(());
}
wln!(w)?;
for finding in findings {
let sev_tag = severity_tag(finding.severity);
let partial_tag = if is_partial {
let always_show = graph.worst_gap_kind() == Some(GapKind::Opaque);
if self.verbose || always_show {
if always_show && !self.verbose {
format!(" {}", "[partial:opaque]".red().bold())
} else {
format!(" {}", "[partial]".yellow().dimmed())
}
} else {
String::new()
}
} else {
String::new()
};
let custom_tag = match &finding.source {
FindingSource::Custom { source_file } => {
let label = if source_file.as_os_str().is_empty() {
"custom".to_string()
} else {
let name = source_file
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_else(|| source_file.to_str().unwrap_or("custom"));
format!("custom: {}", clean(name))
};
format!(" {}", format!("[{label}]").magenta().dimmed())
}
FindingSource::BuiltIn => String::new(),
};
let message_clean = clean(&finding.message);
wln!(
w,
"{}{}{} {}",
sev_tag,
partial_tag,
custom_tag,
message_clean.bold()
)?;
if let Some(ref path) = finding.path {
let source_name_owned = graph
.node(path.source)
.map(|n| clean(&n.name))
.unwrap_or_else(|| "?".to_string());
let source_kind = graph
.node(path.source)
.map(|n| node_kind_label(n.kind))
.unwrap_or("");
if path.edges.len() <= 2 {
w!(
w,
" {} {} {}",
"Path:".bright_black(),
source_name_owned.bright_white(),
format!("({source_kind})").bright_black()
)?;
for edge_id in &path.edges {
if let Some(edge) = graph.edge(*edge_id) {
let target = graph.node(edge.to);
let name = target
.map(|n| clean(&n.name))
.unwrap_or_else(|| "?".to_string());
let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
w!(
w,
" {} {} {}",
"→".bright_black(),
name.bright_white(),
format!("({kind})").bright_black()
)?;
}
}
wln!(w)?;
} else {
wln!(w, " {}:", "Path".bright_black())?;
wln!(
w,
" {} {}",
source_name_owned.bright_white(),
format!("({source_kind})").bright_black()
)?;
for edge_id in &path.edges {
if let Some(edge) = graph.edge(*edge_id) {
let target = graph.node(edge.to);
let name = target
.map(|n| clean(&n.name))
.unwrap_or_else(|| "?".to_string());
let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
wln!(
w,
" {} {} {}",
"→".bright_black(),
name.bright_white(),
format!("({kind})").bright_black()
)?;
}
}
}
if self.verbose {
let mut path_nodes = vec![path.source];
for edge_id in &path.edges {
if let Some(edge) = graph.edge(*edge_id) {
path_nodes.push(edge.to);
}
}
emit_verbose_nodes(w, graph, &path_nodes)?;
}
} else if !finding.nodes_involved.is_empty() {
let nodes = &finding.nodes_involved;
let display: Vec<String> = nodes
.iter()
.take(4)
.filter_map(|&id| graph.node(id))
.map(|n| {
format!(
"{} {}",
clean(&n.name).bright_white(),
format!("({})", node_kind_label(n.kind)).bright_black()
)
})
.collect();
let suffix = if nodes.len() > 4 {
format!(
" {}",
format!("…(+{} more)", nodes.len() - 4).bright_black()
)
} else {
String::new()
};
let connector = if finding.nodes_involved.windows(2).any(|w| {
graph
.edges_from(w[0])
.any(|e| e.to == w[1] && e.kind == EdgeKind::PersistsTo)
}) {
format!(" {} ", "persists→".bright_black())
} else {
format!(" {} ", "→".bright_black())
};
wln!(
w,
" {} {}{}",
"Nodes:".bright_black(),
display.join(&connector),
suffix
)?;
if self.verbose {
emit_verbose_nodes(w, graph, nodes)?;
}
}
let rec = clean(&format_recommendation(&finding.recommendation));
wln!(w, " {} {}", "Recommendation:".green().bold(), rec.green())?;
wln!(w)?;
}
Ok(())
}
}
fn severity_tag(sev: Severity) -> String {
match sev {
Severity::Critical => format!("[{}]", "CRITICAL".bright_red().bold().reversed()),
Severity::High => format!("[{}]", "HIGH".bright_red().bold()),
Severity::Medium => format!("[{}]", "MEDIUM".yellow().bold()),
Severity::Low => format!("[{}]", "LOW".bright_yellow()),
Severity::Info => format!("[{}]", "INFO".bright_cyan()),
}
}
fn node_kind_label(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Step => "step",
NodeKind::Secret => "secret",
NodeKind::Identity => "identity",
NodeKind::Artifact => "artifact",
NodeKind::Image => "action/image",
}
}
fn format_recommendation(rec: &Recommendation) -> String {
match rec {
Recommendation::TsafeRemediation { command, .. } => command.clone(),
Recommendation::CellosRemediation { spec_hint, .. } => spec_hint.clone(),
Recommendation::PinAction { pinned, .. } => format!("Pin to {pinned}"),
Recommendation::ReducePermissions { minimum, .. } => {
format!("Reduce permissions to {minimum}")
}
Recommendation::FederateIdentity { oidc_provider, .. } => {
format!("Replace with {oidc_provider} OIDC")
}
Recommendation::Manual { action } => action.clone(),
}
}
fn emit_verbose_nodes<W: std::io::Write>(
w: &mut W,
graph: &AuthorityGraph,
node_ids: &[usize],
) -> Result<(), TauditError> {
for &id in node_ids {
if let Some(node) = graph.node(id) {
let kind = node_kind_label(node.kind);
let zone = format!("{:?}", node.trust_zone).to_lowercase();
let name_clean = clean(&node.name);
w!(w, " {} ({kind}, {zone})", name_clean.bright_black())?;
if let Some(scope) = node.metadata.get("identity_scope") {
w!(w, ", scope: {}", clean(scope))?;
}
if let Some(perms) = node.metadata.get("permissions") {
w!(w, ", permissions: {}", clean(perms))?;
}
if let Some(digest) = node.metadata.get("digest") {
let digest_clean = clean(digest);
w!(w, ", pin: {}…", &digest_clean[..digest_clean.len().min(12)])?;
}
if node
.metadata
.get("inferred")
.map(|v| v == "true")
.unwrap_or(false)
{
w!(w, " (inferred)")?;
}
wln!(w)?;
}
}
Ok(())
}
pub fn print_banner<W: std::io::Write>(w: &mut W, file_count: usize) -> std::io::Result<()> {
writeln!(
w,
"{}",
format!(
"taudit {} — {} {}",
env!("CARGO_PKG_VERSION"),
file_count,
if file_count == 1 { "file" } else { "files" }
)
.bright_white()
.bold()
)
}
pub struct RunSummary {
pub total_files: usize,
pub files_with_findings: usize,
pub clean_files: usize,
pub partial_files: usize,
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
}
pub fn print_summary<W: std::io::Write>(w: &mut W, s: &RunSummary) -> std::io::Result<()> {
writeln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
if s.clean_files > 0 {
writeln!(
w,
"{}",
format!(
"✓ {} {} clean",
s.clean_files,
if s.clean_files == 1 { "file" } else { "files" }
)
.green()
.bold()
)?;
}
let total_findings = s.critical + s.high + s.medium + s.low;
if total_findings == 0 {
writeln!(w, "{}", "✓ no findings across all files".green().bold())?;
return Ok(());
}
write!(w, "{} ", "Summary".bright_white().bold())?;
let mut parts = Vec::new();
if s.critical > 0 {
parts.push(format!(
"{}",
format!("{} critical", s.critical).bright_red().bold()
));
}
if s.high > 0 {
parts.push(format!("{}", format!("{} high", s.high).bright_red()));
}
if s.medium > 0 {
parts.push(format!("{}", format!("{} medium", s.medium).yellow()));
}
if s.low > 0 {
parts.push(format!("{}", format!("{} low", s.low).bright_yellow()));
}
writeln!(w, "{}", parts.join(" "))?;
writeln!(
w,
"{}",
format!(
" Files with findings: {} / {}",
s.files_with_findings, s.total_files
)
.bright_black()
)?;
if s.partial_files > 0 {
writeln!(
w,
"{}",
format!(
" Partial graphs: {} — findings from partial graphs may be incomplete",
s.partial_files
)
.yellow()
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use taudit_core::finding::{
Finding, FindingCategory, FindingExtras, FindingSource, Recommendation, Severity,
};
use taudit_core::graph::{GapKind, PipelineSource};
use taudit_core::ports::ReportSink;
#[test]
fn strip_control_chars_passes_clean_text_unchanged() {
let clean = "AWS_KEY reaches deploy";
let out = strip_control_chars(clean);
assert!(
matches!(out, Cow::Borrowed(_)),
"clean input must zero-alloc"
);
assert_eq!(out, clean);
}
#[test]
fn strip_control_chars_preserves_newline_and_tab() {
let s = "line1\nline2\tcol";
let out = strip_control_chars(s);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out, s);
}
#[test]
fn strip_control_chars_drops_esc_and_clear_screen() {
let hostile = "\x1b[2J\x1b[Hfake clean output\x1b[0m";
let out = strip_control_chars(hostile);
assert!(!out.contains('\x1b'), "ESC byte must be stripped");
assert_eq!(out, "[2J[Hfake clean output[0m");
}
#[test]
fn strip_control_chars_drops_bel_and_del() {
let hostile = "ding\x07then\x7Fdel";
let out = strip_control_chars(hostile);
assert!(!out.bytes().any(|b| b == 0x07));
assert!(!out.bytes().any(|b| b == 0x7F));
assert_eq!(out, "dingthendel");
}
#[test]
fn strip_control_chars_drops_rtl_and_zwj() {
let hostile = "user\u{202E}name\u{200D}joiner";
let out = strip_control_chars(hostile);
assert!(!out.contains('\u{202E}'));
assert!(!out.contains('\u{200D}'));
assert_eq!(out, "usernamejoiner");
}
#[test]
fn strip_control_chars_preserves_emoji_and_unicode_prose() {
let s = "✓ no findings — Authority Graph: ci.yml";
let out = strip_control_chars(s);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out, s);
}
#[test]
fn strip_control_chars_drops_c1_range() {
let hostile = "before\u{0080}\u{009F}after";
let out = strip_control_chars(hostile);
assert_eq!(out, "beforeafter");
}
fn test_graph() -> AuthorityGraph {
AuthorityGraph::new(PipelineSource {
file: "test.yml".into(),
repo: None,
git_ref: None,
commit_sha: None,
})
}
fn render(graph: &AuthorityGraph) -> String {
colored::control::set_override(false);
let reporter = TerminalReport { verbose: false };
let mut buf: Vec<u8> = Vec::new();
reporter
.emit(&mut buf, graph, &[])
.expect("emit should succeed");
let raw = String::from_utf8(buf).expect("utf-8 output");
strip_ansi(&raw)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1B}' && chars.peek() == Some(&'[') {
chars.next(); for fc in chars.by_ref() {
let cp = fc as u32;
if (0x40..=0x7E).contains(&cp) {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn opaque_gap_header_shows_error_prefix() {
let mut g = test_graph();
g.mark_partial(GapKind::Opaque, "zero steps");
let out = render(&g);
assert!(
out.contains("error: ⛔"),
"expected opaque header 'error: ⛔', got:\n{out}"
);
assert!(
out.contains("[opaque]"),
"expected [opaque] gap label, got:\n{out}"
);
}
#[test]
fn structural_gap_header_shows_warning_prefix() {
let mut g = test_graph();
g.mark_partial(GapKind::Structural, "composite unresolved");
let out = render(&g);
assert!(
out.contains("note: ⚠"),
"expected structural header 'note: ⚠', got:\n{out}"
);
assert!(
out.contains("[structural]"),
"expected [structural] gap label, got:\n{out}"
);
}
#[test]
fn expression_gap_header_shows_note_prefix() {
let mut g = test_graph();
g.mark_partial(GapKind::Expression, "matrix hides paths");
let out = render(&g);
assert!(
out.contains("note: ·"),
"expected expression header 'note: ·', got:\n{out}"
);
assert!(
out.contains("[expression]"),
"expected [expression] gap label, got:\n{out}"
);
}
fn test_finding() -> Finding {
Finding {
severity: Severity::Medium,
category: FindingCategory::UnpinnedAction,
path: None,
nodes_involved: vec![],
message: "test finding for verbosity gating".into(),
recommendation: Recommendation::Manual {
action: "fix".into(),
},
source: FindingSource::BuiltIn,
extras: FindingExtras::default(),
}
}
fn render_with(graph: &AuthorityGraph, findings: &[Finding], verbose: bool) -> String {
colored::control::set_override(false);
let reporter = TerminalReport { verbose };
let mut buf: Vec<u8> = Vec::new();
reporter
.emit(&mut buf, graph, findings)
.expect("emit should succeed");
let raw = String::from_utf8(buf).expect("utf-8 output");
strip_ansi(&raw)
}
#[test]
fn default_quiet_structural_gap_suppresses_inline_tag() {
let mut g = test_graph();
g.mark_partial(GapKind::Structural, "composite unresolved");
let findings = vec![test_finding()];
let out = render_with(&g, &findings, false);
assert!(
out.contains("note: ⚠"),
"expected structural header 'note: ⚠', got:\n{out}"
);
let finding_line = out
.lines()
.find(|l| l.contains("[MEDIUM]"))
.expect("expected a finding line containing [MEDIUM]");
assert!(
!finding_line.contains("[partial]"),
"default-quiet should suppress inline [partial] for Structural, \
but finding line had it: {finding_line}"
);
assert!(
!finding_line.contains("[partial:opaque]"),
"Structural gap must not render [partial:opaque]: {finding_line}"
);
}
#[test]
fn default_quiet_opaque_gap_shows_inline_tag() {
let mut g = test_graph();
g.mark_partial(GapKind::Opaque, "zero steps");
let findings = vec![test_finding()];
let out = render_with(&g, &findings, false);
assert!(
out.contains("error: ⛔"),
"expected opaque header 'error: ⛔', got:\n{out}"
);
let finding_line = out
.lines()
.find(|l| l.contains("[MEDIUM]"))
.expect("expected a finding line containing [MEDIUM]");
assert!(
finding_line.contains("[partial:opaque]"),
"Opaque gap must render inline [partial:opaque] even in quiet mode: {finding_line}"
);
}
#[test]
fn verbose_structural_gap_shows_inline_tag() {
let mut g = test_graph();
g.mark_partial(GapKind::Structural, "composite unresolved");
let findings = vec![test_finding()];
let out = render_with(&g, &findings, true);
let finding_line = out
.lines()
.find(|l| l.contains("[MEDIUM]"))
.expect("expected a finding line containing [MEDIUM]");
assert!(
finding_line.contains("[partial]"),
"verbose should render inline [partial] for Structural gap: {finding_line}"
);
assert!(
!finding_line.contains("[partial:opaque]"),
"Structural gap must not render [partial:opaque] under --verbose: {finding_line}"
);
}
#[test]
fn terminal_output_is_byte_deterministic_across_runs() {
use std::collections::HashMap;
use taudit_core::graph::{EdgeKind, NodeKind, TrustZone};
fn build_graph() -> (AuthorityGraph, Vec<Finding>) {
let mut graph = AuthorityGraph::new(PipelineSource {
file: "ci.yml".into(),
repo: None,
git_ref: None,
commit_sha: None,
});
let secret_a = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
let secret_b = graph.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
let step = graph.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
graph.add_edge(step, secret_a, EdgeKind::HasAccessTo);
graph.add_edge(step, secret_b, EdgeKind::HasAccessTo);
if let Some(node) = graph.nodes.get_mut(step) {
let mut meta: HashMap<String, String> = HashMap::new();
meta.insert("z_field".into(), "z".into());
meta.insert("a_field".into(), "a".into());
meta.insert("m_field".into(), "m".into());
meta.insert("k_field".into(), "k".into());
meta.insert("c_field".into(), "c".into());
node.metadata = meta;
}
graph
.metadata
.insert("trigger".into(), "pull_request".into());
graph.metadata.insert("platform".into(), "github".into());
let findings = vec![Finding {
severity: Severity::High,
category: FindingCategory::AuthorityPropagation,
path: None,
nodes_involved: vec![secret_a, step],
message: "AWS_KEY reaches deploy".into(),
recommendation: Recommendation::Manual {
action: "scope it".into(),
},
source: FindingSource::BuiltIn,
extras: FindingExtras::default(),
}];
(graph, findings)
}
colored::control::set_override(false);
let mut runs: Vec<Vec<u8>> = Vec::with_capacity(9);
for _ in 0..9 {
let (g, f) = build_graph();
let mut buf: Vec<u8> = Vec::new();
TerminalReport { verbose: false }
.emit(&mut buf, &g, &f)
.expect("emit should succeed");
runs.push(buf);
}
let first = &runs[0];
for (i, run) in runs.iter().enumerate().skip(1) {
assert_eq!(
first, run,
"run 0 and run {i} produced byte-different terminal output (non-determinism regression)"
);
}
}
}