fslite_command/render.rs
1//! Turns a [`CommandOutput`] into text: either a human-readable summary
2//! (used by default in `fslite-cli`) or pretty-printed JSON matching the
3//! wire codec exactly (`--json`). Every untrusted string field (node names,
4//! link targets, and paths — which `fslite-core` normalizes but does not
5//! strip control bytes from) is passed through one of three sanitizers
6//! before it reaches a human-readable line, since a malicious filename or
7//! path segment is attacker-controlled input reaching a real terminal.
8//!
9//! Three sanitizers exist because `\n`/`\t` are not uniformly safe to keep:
10//! [`sanitize_name`] strips all control bytes (including `\n`/`\t`), Unicode
11//! bidirectional-override characters, and the Unicode line/paragraph
12//! separators, and is used for *structured* fields (node names, paths, link
13//! targets) where a newline is never legitimate and would otherwise let an
14//! attacker forge extra rows in table-shaped output (e.g. a node named
15//! `a.txt\nfile 999 IMPORTANT.txt` injecting a fake `ls` row), and where a
16//! bidi override could visually spoof the name (e.g. an extension made to
17//! *display* reversed). [`sanitize_for_terminal`] preserves `\n`/`\t` and
18//! the Unicode line/paragraph separators but strips other control bytes and
19//! bidi overrides (which are never legitimate in any context) — used for
20//! free-text fields where raw newlines can be legitimate content.
21//! [`sanitize_preview`] is a stricter tier: it wraps [`sanitize_for_terminal`]
22//! but further escapes `\n`/`\t` and the Unicode line/paragraph separators
23//! into visible escape sequences, for free-text content rendered
24//! *inline* within a single row (currently only search-match previews),
25//! keeping the content visible without letting it masquerade as a row
26//! boundary.
27
28use fslite_core::Node;
29
30use crate::CommandOutput;
31
32/// Unicode bidirectional-control characters that can silently reorder how
33/// surrounding text *displays* without changing its underlying bytes —
34/// e.g. a name ending `\u{202E}gpj.exe` can display as if it ends `.jpg`
35/// reversed. Covers the explicit embeddings/overrides (U+202A-U+202E), the
36/// isolates (U+2066-U+2069), and the weaker marks LRM/RLM/ALM (U+200E,
37/// U+200F, U+061C), which only flip the resolved direction of adjacent
38/// neutral characters (e.g. a filename's extension dot) rather than
39/// reversing a whole run. `char::is_control()` does not catch these; they
40/// are Unicode general category Cf (format), not Cc (control).
41fn is_bidi_override(ch: char) -> bool {
42 matches!(
43 ch,
44 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' | '\u{061C}'
45 )
46}
47
48/// Unicode line/paragraph separators that render as line breaks in many
49/// terminals but aren't caught by `char::is_control()` either (categories
50/// Zl/Zp, not Cc).
51fn is_unicode_linebreak(ch: char) -> bool {
52 matches!(ch, '\u{2028}' | '\u{2029}')
53}
54
55/// Strips ASCII control bytes (except `\n`/`\t`) — including the ESC byte
56/// that begins every ANSI escape sequence — and Unicode bidirectional-override
57/// characters from untrusted text before it is written to a terminal. This
58/// removes the trigger byte outright rather than substituting a visible
59/// placeholder, since the goal is preventing the escape sequence (or a
60/// spoofed display order) from being interpreted at all. `\n`/`\t` and the
61/// Unicode line/paragraph separators (U+2028/U+2029) are preserved, since
62/// they can be legitimate content in free text; bidi overrides are never
63/// legitimate in any context, so they are always stripped.
64///
65/// Use this only for genuinely free-text fields where a literal `\n`/`\t`
66/// can be legitimate content. For structured fields (node names, paths, link
67/// targets), use [`sanitize_name`] instead — those must never contain a
68/// newline, since one would let an attacker forge extra rows in
69/// table-shaped output. For free-text fields rendered inline within a single
70/// row, use [`sanitize_preview`] instead — it wraps this function and
71/// further escapes `\n`/`\t` and the Unicode line/paragraph separators to
72/// prevent them from being mistaken for separators.
73pub fn sanitize_for_terminal(raw: &str) -> String {
74 raw.chars()
75 .filter(|&ch| {
76 ch == '\n'
77 || ch == '\t'
78 || is_unicode_linebreak(ch)
79 || (!ch.is_control() && !is_bidi_override(ch))
80 })
81 .collect()
82}
83
84/// Stricter sibling of [`sanitize_for_terminal`] for structured fields
85/// (node names, paths, link targets) where a newline is never legitimate
86/// content. Strips every ASCII control byte (including `\n`/`\t`), every
87/// Unicode bidirectional-override character, and the Unicode line/paragraph
88/// separators, so a hostile name/path can neither inject extra lines that
89/// masquerade as separate output rows nor visually spoof its own content
90/// (e.g. a bidi override making an extension display reversed).
91pub fn sanitize_name(raw: &str) -> String {
92 raw.chars()
93 .filter(|&ch| !ch.is_control() && !is_bidi_override(ch) && !is_unicode_linebreak(ch))
94 .collect()
95}
96
97/// [`sanitize_for_terminal`], with `\n`/`\t` and the Unicode line/paragraph
98/// separators (U+2028/U+2029) then escaped into visible escape sequences
99/// instead of passed through raw. Use this for free-text content
100/// rendered *inline* within a single table-shaped output row (currently
101/// only search-match previews): a real newline (ASCII or Unicode) in the
102/// underlying file content stays visible to the user, but can never be
103/// mistaken for a row boundary the way a raw line break could.
104pub fn sanitize_preview(raw: &str) -> String {
105 let mut escaped = String::with_capacity(raw.len());
106 for ch in sanitize_for_terminal(raw).chars() {
107 match ch {
108 '\n' => escaped.push_str("\\n"),
109 '\t' => escaped.push_str("\\t"),
110 '\u{2028}' => escaped.push_str("\\u{2028}"),
111 '\u{2029}' => escaped.push_str("\\u{2029}"),
112 other => escaped.push(other),
113 }
114 }
115 escaped
116}
117
118fn render_node_line(node: &Node) -> String {
119 format!(
120 "{:<10} {:>10} {}",
121 format!("{:?}", node.kind).to_lowercase(),
122 node.logical_size,
123 sanitize_name(&node.name)
124 )
125}
126
127/// Renders a [`CommandOutput`] as human-readable text.
128pub fn render_human(output: &CommandOutput) -> String {
129 match output {
130 CommandOutput::Usage(usage) => format!(
131 "active: {} bytes / {} nodes\ntrashed: {} bytes / {} nodes\nquota: {} bytes / {} nodes",
132 usage.active_logical_bytes,
133 usage.active_nodes,
134 usage.trashed_logical_bytes,
135 usage.trashed_nodes,
136 usage.max_logical_bytes,
137 usage.max_nodes,
138 ),
139 CommandOutput::Node(node) => render_node_line(node),
140 CommandOutput::Exists(found) => found.to_string(),
141 CommandOutput::Nodes(page) => page
142 .items
143 .iter()
144 .map(render_node_line)
145 .collect::<Vec<_>>()
146 .join("\n"),
147 CommandOutput::Tree(page) => page
148 .items
149 .iter()
150 .map(|entry| {
151 format!(
152 "{}{}",
153 " ".repeat(entry.depth as usize),
154 sanitize_name(entry.path.as_str())
155 )
156 })
157 .collect::<Vec<_>>()
158 .join("\n"),
159 CommandOutput::Content { bytes, .. } => String::from_utf8_lossy(bytes).into_owned(),
160 CommandOutput::Unit => "ok".to_string(),
161 CommandOutput::LinkTarget(target) => sanitize_name(target.as_str()),
162 CommandOutput::Trash(entry) => format!(
163 "{} (was {})",
164 entry.id,
165 sanitize_name(entry.original_path.as_str())
166 ),
167 CommandOutput::TrashList(page) => page
168 .items
169 .iter()
170 .map(|entry| {
171 format!(
172 "{} {}",
173 entry.id,
174 sanitize_name(entry.original_path.as_str())
175 )
176 })
177 .collect::<Vec<_>>()
178 .join("\n"),
179 CommandOutput::SearchMatches(page) => page
180 .items
181 .iter()
182 .map(|m| {
183 format!(
184 "{}: {}",
185 sanitize_name(m.path.as_str()),
186 sanitize_preview(&String::from_utf8_lossy(&m.preview))
187 )
188 })
189 .collect::<Vec<_>>()
190 .join("\n"),
191 CommandOutput::Changes(page) => page
192 .items
193 .iter()
194 .map(|change| format!("{} {:?}", change.sequence, change.kind))
195 .collect::<Vec<_>>()
196 .join("\n"),
197 CommandOutput::Batch(results) => format!("{} operations completed", results.len()),
198 }
199}
200
201/// Renders a [`CommandOutput`] as pretty-printed JSON, exactly matching the
202/// serde wire codec (round-trippable back into a `CommandOutput`).
203pub fn render_json(output: &CommandOutput) -> String {
204 serde_json::to_string_pretty(output).expect("CommandOutput always serializes")
205}