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 = 2;
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 => return Ok(()),
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        };
63        let reset = "\x1b[0m";
64
65        writeln!(
66            w,
67            "  {}[{}]{} {} — {}",
68            severity_color, finding.severity, reset, finding.rule_id, finding.title
69        )?;
70        writeln!(w, "    {}", finding.description)?;
71
72        // Display detailed evidence for homoglyph findings
73        for evidence in &finding.evidence {
74            if let Evidence::HomoglyphAnalysis {
75                raw,
76                escaped,
77                suspicious_chars,
78            } = evidence
79            {
80                writeln!(w)?;
81                // Visual line with markers
82                let visual = format_visual_with_markers(raw, suspicious_chars);
83                writeln!(w, "    Visual:  {visual}")?;
84                writeln!(w, "    Escaped: \x1b[33m{escaped}\x1b[0m")?;
85
86                // Suspicious bytes section
87                if !suspicious_chars.is_empty() {
88                    writeln!(w)?;
89                    writeln!(w, "    \x1b[33mSuspicious bytes:\x1b[0m")?;
90                    for sc in suspicious_chars {
91                        writeln!(
92                            w,
93                            "      {:08x}: {} {:6} {}",
94                            sc.offset, sc.hex_bytes, sc.codepoint, sc.description
95                        )?;
96                    }
97                }
98            }
99        }
100    }
101
102    if verdict.action == Action::Block {
103        writeln!(
104            w,
105            "  Bypass: prefix your command with TIRITH=0 (applies to that command only)"
106        )?;
107    }
108
109    Ok(())
110}
111
112/// Format a string with red markers on suspicious characters
113fn format_visual_with_markers(
114    raw: &str,
115    suspicious_chars: &[crate::verdict::SuspiciousChar],
116) -> String {
117    use std::collections::HashSet;
118
119    // Build a set of suspicious byte offsets
120    let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
121
122    let mut result = String::new();
123    let mut byte_offset = 0;
124
125    for ch in raw.chars() {
126        if suspicious_offsets.contains(&byte_offset) {
127            // Red background for suspicious character
128            result.push_str("\x1b[41m\x1b[97m"); // red bg, white fg
129            result.push(ch);
130            result.push_str("\x1b[0m"); // reset
131        } else {
132            result.push(ch);
133        }
134        byte_offset += ch.len_utf8();
135    }
136
137    result
138}
139
140/// Write human-readable output to stderr, respecting TTY detection.
141/// If stderr is not a TTY, strip ANSI colors.
142pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
143    let stderr = std::io::stderr();
144    let is_tty = is_terminal::is_terminal(&stderr);
145
146    if is_tty {
147        write_human(verdict, stderr.lock())
148    } else {
149        write_human_no_color(verdict, stderr.lock())
150    }
151}
152
153/// Write human-readable output without ANSI colors.
154fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
155    if verdict.findings.is_empty() {
156        return Ok(());
157    }
158
159    let action_str = match verdict.action {
160        Action::Allow => return Ok(()),
161        Action::Warn => "WARNING",
162        Action::Block => "BLOCKED",
163    };
164
165    writeln!(w, "tirith: {action_str}")?;
166
167    for finding in &verdict.findings {
168        writeln!(
169            w,
170            "  [{}] {} — {}",
171            finding.severity, finding.rule_id, finding.title
172        )?;
173        writeln!(w, "    {}", finding.description)?;
174
175        // Display detailed evidence for homoglyph findings (no color)
176        for evidence in &finding.evidence {
177            if let Evidence::HomoglyphAnalysis {
178                raw,
179                escaped,
180                suspicious_chars,
181            } = evidence
182            {
183                writeln!(w)?;
184                // Visual line with markers (using brackets instead of color)
185                let visual = format_visual_with_brackets(raw, suspicious_chars);
186                writeln!(w, "    Visual:  {visual}")?;
187                writeln!(w, "    Escaped: {escaped}")?;
188
189                // Suspicious bytes section
190                if !suspicious_chars.is_empty() {
191                    writeln!(w)?;
192                    writeln!(w, "    Suspicious bytes:")?;
193                    for sc in suspicious_chars {
194                        writeln!(
195                            w,
196                            "      {:08x}: {} {:6} {}",
197                            sc.offset, sc.hex_bytes, sc.codepoint, sc.description
198                        )?;
199                    }
200                }
201            }
202        }
203    }
204
205    if verdict.action == Action::Block {
206        writeln!(
207            w,
208            "  Bypass: prefix your command with TIRITH=0 (applies to that command only)"
209        )?;
210    }
211
212    Ok(())
213}
214
215/// Format a string with brackets around suspicious characters (for no-color mode)
216fn format_visual_with_brackets(
217    raw: &str,
218    suspicious_chars: &[crate::verdict::SuspiciousChar],
219) -> String {
220    use std::collections::HashSet;
221
222    let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
223
224    let mut result = String::new();
225    let mut byte_offset = 0;
226
227    for ch in raw.chars() {
228        if suspicious_offsets.contains(&byte_offset) {
229            result.push('[');
230            result.push(ch);
231            result.push(']');
232        } else {
233            result.push(ch);
234        }
235        byte_offset += ch.len_utf8();
236    }
237
238    result
239}