Skip to main content

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}