Skip to main content

tirith_core/
output.rs

1use std::io::Write;
2
3use crate::verdict::{Action, Evidence, Finding, Severity, Verdict};
4
5const SCHEMA_VERSION: u32 = 3;
6
7/// JSON output wrapper with schema version.
8#[derive(serde::Serialize)]
9pub struct JsonOutput<'a> {
10    pub schema_version: u32,
11    pub action: Action,
12    pub findings: &'a [Finding],
13    pub tier_reached: u8,
14    pub bypass_requested: bool,
15    pub bypass_honored: bool,
16    pub interactive_detected: bool,
17    pub policy_path_used: &'a Option<String>,
18    pub timings_ms: &'a crate::verdict::Timings,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub urls_extracted_count: Option<usize>,
21}
22
23/// Write verdict as JSON to the given writer.
24pub fn write_json(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
25    let output = JsonOutput {
26        schema_version: SCHEMA_VERSION,
27        action: verdict.action,
28        findings: &verdict.findings,
29        tier_reached: verdict.tier_reached,
30        bypass_requested: verdict.bypass_requested,
31        bypass_honored: verdict.bypass_honored,
32        interactive_detected: verdict.interactive_detected,
33        policy_path_used: &verdict.policy_path_used,
34        timings_ms: &verdict.timings_ms,
35        urls_extracted_count: verdict.urls_extracted_count,
36    };
37    serde_json::to_writer(&mut w, &output)?;
38    writeln!(w)?;
39    Ok(())
40}
41
42/// Write human-readable verdict to stderr.
43pub fn write_human(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
44    if verdict.findings.is_empty() {
45        return Ok(());
46    }
47
48    let action_str = match verdict.action {
49        Action::Allow => "INFO",
50        Action::Warn => "WARNING",
51        Action::Block => "BLOCKED",
52    };
53
54    writeln!(w, "tirith: {action_str}")?;
55
56    for finding in &verdict.findings {
57        let severity_color = match finding.severity {
58            Severity::Critical => "\x1b[91m", // bright red
59            Severity::High => "\x1b[31m",     // red
60            Severity::Medium => "\x1b[33m",   // yellow
61            Severity::Low => "\x1b[36m",      // cyan
62            Severity::Info => "\x1b[90m",     // dim/gray
63        };
64        let reset = "\x1b[0m";
65
66        writeln!(
67            w,
68            "  {}[{}]{} {} — {}",
69            severity_color, finding.severity, reset, finding.rule_id, finding.title
70        )?;
71        writeln!(w, "    {}", finding.description)?;
72
73        // Display detailed evidence for homoglyph findings
74        for evidence in &finding.evidence {
75            if let Evidence::HomoglyphAnalysis {
76                raw,
77                escaped,
78                suspicious_chars,
79            } = evidence
80            {
81                writeln!(w)?;
82                // Visual line with markers
83                let visual = format_visual_with_markers(raw, suspicious_chars);
84                writeln!(w, "    Visual:  {visual}")?;
85                writeln!(w, "    Escaped: \x1b[33m{escaped}\x1b[0m")?;
86
87                // Suspicious bytes section
88                if !suspicious_chars.is_empty() {
89                    writeln!(w)?;
90                    writeln!(w, "    \x1b[33mSuspicious bytes:\x1b[0m")?;
91                    for sc in suspicious_chars {
92                        writeln!(
93                            w,
94                            "      {:08x}: {} {:6} {}",
95                            sc.offset, sc.hex_bytes, sc.codepoint, sc.description
96                        )?;
97                    }
98                }
99            }
100        }
101    }
102
103    if verdict.action == Action::Block {
104        writeln!(
105            w,
106            "  Bypass: prefix your command with TIRITH=0 (applies to that command only)"
107        )?;
108    }
109
110    Ok(())
111}
112
113/// Format a string with red markers on suspicious characters
114fn format_visual_with_markers(
115    raw: &str,
116    suspicious_chars: &[crate::verdict::SuspiciousChar],
117) -> String {
118    use std::collections::HashSet;
119
120    // Build a set of suspicious byte offsets
121    let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
122
123    let mut result = String::new();
124    let mut byte_offset = 0;
125
126    for ch in raw.chars() {
127        if suspicious_offsets.contains(&byte_offset) {
128            // Red background for suspicious character
129            result.push_str("\x1b[41m\x1b[97m"); // red bg, white fg
130            result.push(ch);
131            result.push_str("\x1b[0m"); // reset
132        } else {
133            result.push(ch);
134        }
135        byte_offset += ch.len_utf8();
136    }
137
138    result
139}
140
141/// Write human-readable output to stderr, respecting TTY detection.
142/// If stderr is not a TTY, strip ANSI colors.
143pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
144    let stderr = std::io::stderr();
145    let is_tty = is_terminal::is_terminal(&stderr);
146
147    if is_tty {
148        write_human(verdict, stderr.lock())
149    } else {
150        write_human_no_color(verdict, stderr.lock())
151    }
152}
153
154/// Write human-readable output without ANSI colors.
155fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
156    if verdict.findings.is_empty() {
157        return Ok(());
158    }
159
160    let action_str = match verdict.action {
161        Action::Allow => "INFO",
162        Action::Warn => "WARNING",
163        Action::Block => "BLOCKED",
164    };
165
166    writeln!(w, "tirith: {action_str}")?;
167
168    for finding in &verdict.findings {
169        writeln!(
170            w,
171            "  [{}] {} — {}",
172            finding.severity, finding.rule_id, finding.title
173        )?;
174        writeln!(w, "    {}", finding.description)?;
175
176        // Display detailed evidence for homoglyph findings (no color)
177        for evidence in &finding.evidence {
178            if let Evidence::HomoglyphAnalysis {
179                raw,
180                escaped,
181                suspicious_chars,
182            } = evidence
183            {
184                writeln!(w)?;
185                // Visual line with markers (using brackets instead of color)
186                let visual = format_visual_with_brackets(raw, suspicious_chars);
187                writeln!(w, "    Visual:  {visual}")?;
188                writeln!(w, "    Escaped: {escaped}")?;
189
190                // Suspicious bytes section
191                if !suspicious_chars.is_empty() {
192                    writeln!(w)?;
193                    writeln!(w, "    Suspicious bytes:")?;
194                    for sc in suspicious_chars {
195                        writeln!(
196                            w,
197                            "      {:08x}: {} {:6} {}",
198                            sc.offset, sc.hex_bytes, sc.codepoint, sc.description
199                        )?;
200                    }
201                }
202            }
203        }
204    }
205
206    if verdict.action == Action::Block {
207        writeln!(
208            w,
209            "  Bypass: prefix your command with TIRITH=0 (applies to that command only)"
210        )?;
211    }
212
213    Ok(())
214}
215
216/// Format a string with brackets around suspicious characters (for no-color mode)
217fn format_visual_with_brackets(
218    raw: &str,
219    suspicious_chars: &[crate::verdict::SuspiciousChar],
220) -> String {
221    use std::collections::HashSet;
222
223    let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
224
225    let mut result = String::new();
226    let mut byte_offset = 0;
227
228    for ch in raw.chars() {
229        if suspicious_offsets.contains(&byte_offset) {
230            result.push('[');
231            result.push(ch);
232            result.push(']');
233        } else {
234            result.push(ch);
235        }
236        byte_offset += ch.len_utf8();
237    }
238
239    result
240}