use std::io::Write;
use crate::verdict::{Action, Evidence, Finding, Severity, Verdict};
const SCHEMA_VERSION: u32 = 3;
#[derive(serde::Serialize)]
pub struct JsonOutput<'a> {
pub schema_version: u32,
pub action: Action,
pub findings: &'a [Finding],
pub tier_reached: u8,
pub bypass_requested: bool,
pub bypass_honored: bool,
pub interactive_detected: bool,
pub policy_path_used: &'a Option<String>,
pub timings_ms: &'a crate::verdict::Timings,
#[serde(skip_serializing_if = "Option::is_none")]
pub urls_extracted_count: Option<usize>,
}
pub fn write_json(
verdict: &Verdict,
custom_patterns: &[String],
mut w: impl Write,
) -> std::io::Result<()> {
let redacted_findings = crate::redact::redacted_findings(&verdict.findings, custom_patterns);
let output = JsonOutput {
schema_version: SCHEMA_VERSION,
action: verdict.action,
findings: &redacted_findings,
tier_reached: verdict.tier_reached,
bypass_requested: verdict.bypass_requested,
bypass_honored: verdict.bypass_honored,
interactive_detected: verdict.interactive_detected,
policy_path_used: &verdict.policy_path_used,
timings_ms: &verdict.timings_ms,
urls_extracted_count: verdict.urls_extracted_count,
};
serde_json::to_writer(&mut w, &output)?;
writeln!(w)?;
Ok(())
}
pub fn write_human(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
if verdict.findings.is_empty() {
return Ok(());
}
let action_str = match verdict.action {
Action::Allow => "INFO",
Action::Warn => "WARNING",
Action::Block => "BLOCKED",
};
writeln!(w, "tirith: {action_str}")?;
for finding in &verdict.findings {
let severity_color = match finding.severity {
Severity::Critical => "\x1b[91m", Severity::High => "\x1b[31m", Severity::Medium => "\x1b[33m", Severity::Low => "\x1b[36m", Severity::Info => "\x1b[90m", };
let reset = "\x1b[0m";
writeln!(
w,
" {}[{}]{} {} — {}",
severity_color, finding.severity, reset, finding.rule_id, finding.title
)?;
writeln!(w, " {}", finding.description)?;
for evidence in &finding.evidence {
if let Evidence::HomoglyphAnalysis {
raw,
escaped,
suspicious_chars,
} = evidence
{
writeln!(w)?;
let visual = format_visual_with_markers(raw, suspicious_chars);
writeln!(w, " Visual: {visual}")?;
writeln!(w, " Escaped: \x1b[33m{escaped}\x1b[0m")?;
if !suspicious_chars.is_empty() {
writeln!(w)?;
writeln!(w, " \x1b[33mSuspicious bytes:\x1b[0m")?;
for sc in suspicious_chars {
writeln!(
w,
" {:08x}: {} {:6} {}",
sc.offset, sc.hex_bytes, sc.codepoint, sc.description
)?;
}
}
}
}
}
if verdict.action == Action::Block {
writeln!(
w,
" Bypass: prefix your command with TIRITH=0 (applies to that command only)"
)?;
}
Ok(())
}
fn format_visual_with_markers(
raw: &str,
suspicious_chars: &[crate::verdict::SuspiciousChar],
) -> String {
use std::collections::HashSet;
let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
let mut result = String::new();
let mut byte_offset = 0;
for ch in raw.chars() {
if suspicious_offsets.contains(&byte_offset) {
result.push_str("\x1b[41m\x1b[97m"); result.push(ch);
result.push_str("\x1b[0m"); } else {
result.push(ch);
}
byte_offset += ch.len_utf8();
}
result
}
pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
let stderr = std::io::stderr();
let is_tty = is_terminal::is_terminal(&stderr);
if is_tty {
write_human(verdict, stderr.lock())
} else {
write_human_no_color(verdict, stderr.lock())
}
}
fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
if verdict.findings.is_empty() {
return Ok(());
}
let action_str = match verdict.action {
Action::Allow => "INFO",
Action::Warn => "WARNING",
Action::Block => "BLOCKED",
};
writeln!(w, "tirith: {action_str}")?;
for finding in &verdict.findings {
writeln!(
w,
" [{}] {} — {}",
finding.severity, finding.rule_id, finding.title
)?;
writeln!(w, " {}", finding.description)?;
for evidence in &finding.evidence {
if let Evidence::HomoglyphAnalysis {
raw,
escaped,
suspicious_chars,
} = evidence
{
writeln!(w)?;
let visual = format_visual_with_brackets(raw, suspicious_chars);
writeln!(w, " Visual: {visual}")?;
writeln!(w, " Escaped: {escaped}")?;
if !suspicious_chars.is_empty() {
writeln!(w)?;
writeln!(w, " Suspicious bytes:")?;
for sc in suspicious_chars {
writeln!(
w,
" {:08x}: {} {:6} {}",
sc.offset, sc.hex_bytes, sc.codepoint, sc.description
)?;
}
}
}
}
}
if verdict.action == Action::Block {
writeln!(
w,
" Bypass: prefix your command with TIRITH=0 (applies to that command only)"
)?;
}
Ok(())
}
fn format_visual_with_brackets(
raw: &str,
suspicious_chars: &[crate::verdict::SuspiciousChar],
) -> String {
use std::collections::HashSet;
let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
let mut result = String::new();
let mut byte_offset = 0;
for ch in raw.chars() {
if suspicious_offsets.contains(&byte_offset) {
result.push('[');
result.push(ch);
result.push(']');
} else {
result.push(ch);
}
byte_offset += ch.len_utf8();
}
result
}