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