1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! `tirith view <file>` — render a file with terminal-deception sequences
//! neutralized and a sidecar finding list.
//!
//! Streams the file in 64 KiB chunks through [`tirith_core::engine::analyze_output_chunk`]
//! so the byte-scanner state machine carries across chunk boundaries (an escape
//! sequence split on a 64 KiB boundary is still detected). Output is neutralized by
//! stripping ANSI escapes (CSI/OSC/APC/DCS) and zero-width chars — plain text only.
//!
//! Exit codes: 0 Allow, 1 Block (High finding), 2 Warn. `Action::WarnAck` folds
//! back to 2 here (no interactive ack channel in `tirith view`).
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use tirith_core::engine::{self, OutputAnalyzerState};
use tirith_core::verdict::{Action, Verdict};
/// 64 KiB streaming chunks. Matches the M7 ch1 spec.
const CHUNK_BYTES: usize = 64 * 1024;
/// Hard ceiling on the default scan window. Anything larger requires
/// explicit `--max-bytes`. v1 cap is 16 MiB.
pub const DEFAULT_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// Entry point. Reads `path` (or stdin when `None`), runs the output-direction
/// analyzer over the bytes in streaming chunks, prints the sanitized content
/// to stdout, and prints the finding list to stderr (or as JSON when `json`).
pub fn run(path: Option<&Path>, max_bytes: u64, json: bool) -> i32 {
let mut state = OutputAnalyzerState::default();
let mut sanitized = Vec::new();
let mut total_bytes: u64 = 0;
let mut truncated = false;
let read_result: std::io::Result<()> = (|| {
let mut reader: Box<dyn BufRead> = match path {
Some(p) => Box::new(BufReader::with_capacity(CHUNK_BYTES, File::open(p)?)),
None => Box::new(BufReader::with_capacity(
CHUNK_BYTES,
std::io::stdin().lock(),
)),
};
let mut buf = vec![0u8; CHUNK_BYTES];
loop {
// Honor the byte cap: cap each read to whatever's left of
// `max_bytes`. When the remaining budget is 0, stop.
let remaining = max_bytes.saturating_sub(total_bytes);
if remaining == 0 {
// Probe one extra byte to distinguish truncation from a clean EOF.
let mut probe = [0u8; 1];
let n = reader.read(&mut probe)?;
if n > 0 {
truncated = true;
}
break;
}
let want = std::cmp::min(buf.len(), remaining as usize);
let n = reader.read(&mut buf[..want])?;
if n == 0 {
break;
}
total_bytes += n as u64;
let chunk_str = String::from_utf8_lossy(&buf[..n]).into_owned();
let _ = engine::analyze_output_chunk(&chunk_str, &mut state);
sanitize_into(&buf[..n], &mut sanitized);
}
Ok(())
})();
if let Err(e) = read_result {
eprintln!(
"tirith view: failed to read {}: {e}",
path.map(|p| p.display().to_string())
.unwrap_or_else(|| "<stdin>".to_string())
);
return 1;
}
let verdict = engine::analyze_output_finalize_mut(&mut state);
if json {
return emit_json(path, &verdict, total_bytes, truncated);
}
// Human path: write the sanitized content to stdout (so callers can
// `tirith view foo | less`), and the findings/banner to stderr.
let _ = std::io::stdout().lock().write_all(&sanitized);
if !sanitized.is_empty() && !sanitized.ends_with(b"\n") {
let _ = writeln!(std::io::stdout().lock());
}
print_findings_human(&verdict, path, total_bytes, truncated);
// Translate the Verdict to an exit code via the standard mapping.
verdict.action.exit_code()
}
/// Strip ANSI / OSC escape sequences and zero-width characters from `chunk`
/// into `out`. Intentionally simple — we render plain text only.
fn sanitize_into(chunk: &[u8], out: &mut Vec<u8>) {
let mut i = 0;
let n = chunk.len();
while i < n {
let b = chunk[i];
if b == 0x1B {
if i + 1 < n {
match chunk[i + 1] {
b'[' => {
// CSI — final byte 0x40..=0x7E. Skip to and including final.
let mut j = i + 2;
while j < n {
let cb = chunk[j];
if (0x40..=0x7E).contains(&cb) {
j += 1;
break;
}
j += 1;
}
i = j;
continue;
}
b']' | b'_' | b'P' => {
// OSC / APC / DCS — terminated by BEL (0x07) or ST (\e\\).
let mut j = i + 2;
while j < n {
if chunk[j] == 0x07 {
j += 1;
break;
}
if chunk[j] == 0x1B && j + 1 < n && chunk[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
_ => {
// Lone ESC — drop it.
i += 2;
continue;
}
}
} else {
// Trailing ESC, drop.
break;
}
}
// Drop CR not followed by LF (display-overwriting); keep CRLF.
if b == b'\r' {
if i + 1 < n && chunk[i + 1] == b'\n' {
out.push(b'\r');
out.push(b'\n');
i += 2;
continue;
}
i += 1;
continue;
}
// Other low control chars except \t and \n: drop.
if b < 0x20 && b != b'\t' && b != b'\n' {
i += 1;
continue;
}
if b == 0x7F {
i += 1;
continue;
}
// Strip zero-width characters. Multi-byte → decode the char.
if b >= 0xc0 {
let remaining = &chunk[i..];
if let Some(ch) = std::str::from_utf8(remaining)
.ok()
.or_else(|| std::str::from_utf8(&remaining[..remaining.len().min(4)]).ok())
.and_then(|s| s.chars().next())
{
if is_strippable_zero_width(ch) {
i += ch.len_utf8();
continue;
}
let len = ch.len_utf8();
out.extend_from_slice(&chunk[i..i + len]);
i += len;
continue;
}
}
out.push(b);
i += 1;
}
}
fn is_strippable_zero_width(ch: char) -> bool {
matches!(
ch,
'\u{200B}' // ZWSP
| '\u{200C}' // ZWNJ
| '\u{200D}' // ZWJ
| '\u{2060}' // WORD JOINER
| '\u{FEFF}' // ZWNBSP / BOM
) || ('\u{E0000}'..='\u{E007F}').contains(&ch)
}
fn print_findings_human(verdict: &Verdict, path: Option<&Path>, total_bytes: u64, truncated: bool) {
let label = path
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<stdin>".to_string());
let banner = match verdict.action {
Action::Allow => "tirith view: clean",
Action::Warn | Action::WarnAck => "tirith view: warnings",
Action::Block => "tirith view: blocked",
};
eprintln!("{banner} — {label} ({total_bytes} bytes scanned)");
if truncated {
eprintln!(
" warning: file exceeded --max-bytes; only the first {total_bytes} bytes were scanned"
);
}
for f in &verdict.findings {
eprintln!(" [{}] {} — {}", f.severity, f.rule_id, f.title);
eprintln!(" {}", f.description);
}
}
fn emit_json(path: Option<&Path>, verdict: &Verdict, total_bytes: u64, truncated: bool) -> i32 {
#[derive(serde::Serialize)]
struct Out<'a> {
schema_version: u32,
path: Option<String>,
action: Action,
bytes_scanned: u64,
truncated: bool,
findings: &'a [tirith_core::verdict::Finding],
timings_ms: &'a tirith_core::verdict::Timings,
}
let out = Out {
schema_version: 1,
path: path.map(|p| p.display().to_string()),
action: verdict.action,
bytes_scanned: total_bytes,
truncated,
findings: &verdict.findings,
timings_ms: &verdict.timings_ms,
};
let mut stdout = std::io::stdout().lock();
if serde_json::to_writer_pretty(&mut stdout, &out).is_err() || writeln!(stdout).is_err() {
eprintln!("tirith view: failed to write JSON output");
return 1;
}
verdict.action.exit_code()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn view_clean_file_exits_zero() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"hello world\n").unwrap();
let code = run(Some(f.path()), DEFAULT_MAX_BYTES, false);
assert_eq!(code, 0);
}
#[test]
fn view_osc52_flags_findings_and_strips_sequence() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"before\x1b]52;c;aGVsbG8=\x07after\n").unwrap();
// We can't easily capture stdout here; just assert the exit code.
let code = run(Some(f.path()), DEFAULT_MAX_BYTES, false);
assert_eq!(code, Action::Block.exit_code(), "OSC 52 must block (High)");
}
#[test]
fn sanitize_strips_csi_and_osc() {
let mut out = Vec::new();
sanitize_into(b"a\x1b[31mred\x1b[0mb", &mut out);
assert_eq!(out, b"aredb");
out.clear();
sanitize_into(b"prefix\x1b]52;c;aGVsbG8=\x07suffix", &mut out);
assert_eq!(out, b"prefixsuffix");
}
#[test]
fn sanitize_keeps_tabs_and_newlines() {
let mut out = Vec::new();
sanitize_into(b"a\tb\nc\r\nd", &mut out);
assert_eq!(out, b"a\tb\nc\r\nd");
}
#[test]
fn sanitize_strips_zero_width() {
let mut out = Vec::new();
sanitize_into("a\u{200B}b\u{200D}c".as_bytes(), &mut out);
assert_eq!(out, b"abc");
}
}