From 3300625c9497ea73af185d972f57e493d17cb629 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 5 Jun 2026 14:49:16 -0500 Subject: [PATCH] fix(find): honor print0 and negated type predicates --- crates/bashkit/src/builtins/ls/find.rs | 44 ++++++++++-- .../integration/find_multi_path_tests.rs | 70 +++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/bashkit/src/builtins/ls/find.rs b/crates/bashkit/src/builtins/ls/find.rs index d3efcf5d5..e3f6b2d8d 100644 --- a/crates/bashkit/src/builtins/ls/find.rs +++ b/crates/bashkit/src/builtins/ls/find.rs @@ -19,6 +19,7 @@ pub(super) struct FindOptions { pub(super) max_depth: Option, pub(super) min_depth: Option, pub(super) printf_format: Option, + pub(super) print0: bool, /// -exec/-execdir command template (args before \; or +) pub(super) exec_args: Vec, /// true if -exec uses + (batch mode), false for \; (per-file mode) @@ -27,6 +28,8 @@ pub(super) struct FindOptions { pub(super) negate_name: bool, /// Negate the -path predicate pub(super) negate_path: bool, + /// Negate the -type predicate + pub(super) negate_type: bool, } /// The find builtin - search for files. @@ -58,10 +61,12 @@ pub(super) fn parse_find_args( max_depth: None, min_depth: None, printf_format: None, + print0: false, exec_args: Vec::new(), exec_batch: false, negate_name: false, negate_path: false, + negate_type: false, }; let mut negate_next = false; @@ -107,7 +112,13 @@ pub(super) fn parse_find_args( } let t = &args[i]; match t.as_str() { - "f" | "d" | "l" => opts.type_filter = Some(t.chars().next().unwrap()), + "f" | "d" | "l" => { + opts.type_filter = Some(t.chars().next().unwrap()); + if negate_next { + opts.negate_type = true; + negate_next = false; + } + } _ => { return Err(ExecResult::err(format!("find: unknown type '{}'\n", t), 1)); } @@ -149,9 +160,12 @@ pub(super) fn parse_find_args( } } } - "-print" | "-print0" => { + "-print" => { // Default action, ignore } + "-print0" => { + opts.print0 = true; + } "-printf" => { i += 1; if i >= args.len() { @@ -193,6 +207,13 @@ pub(super) fn parse_find_args( i += 1; } + if negate_next { + return Err(ExecResult::err( + "find: missing predicate after '-not'\n".to_string(), + 1, + )); + } + if paths.is_empty() { paths.push(".".to_string()); } @@ -223,10 +244,12 @@ async fn collect_find_plan_data( max_depth: opts.max_depth, min_depth: opts.min_depth, printf_format: None, // Don't format, just collect paths + print0: false, exec_args: Vec::new(), exec_batch: false, negate_name: opts.negate_name, negate_path: opts.negate_path, + negate_type: opts.negate_type, }; let mut output = String::new(); for path_str in search_paths { @@ -409,9 +432,18 @@ fn find_recursive<'a>( // Check type filter let type_matches = match opts.type_filter { - Some('f') => metadata.file_type.is_file(), - Some('d') => metadata.file_type.is_dir(), - Some('l') => metadata.file_type.is_symlink(), + Some('f') => { + let m = metadata.file_type.is_file(); + if opts.negate_type { !m } else { m } + } + Some('d') => { + let m = metadata.file_type.is_dir(); + if opts.negate_type { !m } else { m } + } + Some('l') => { + let m = metadata.file_type.is_symlink(); + if opts.negate_type { !m } else { m } + } _ => true, }; @@ -445,7 +477,7 @@ fn find_recursive<'a>( output.push_str(&find_printf_format(fmt, display_path, &metadata)); } else { output.push_str(display_path); - output.push('\n'); + output.push(if opts.print0 { '\0' } else { '\n' }); } } diff --git a/crates/bashkit/tests/integration/find_multi_path_tests.rs b/crates/bashkit/tests/integration/find_multi_path_tests.rs index 78d09219d..ed0e6048f 100644 --- a/crates/bashkit/tests/integration/find_multi_path_tests.rs +++ b/crates/bashkit/tests/integration/find_multi_path_tests.rs @@ -173,3 +173,73 @@ async fn find_exec_with_missing_path_preserves_error_and_exec_output() { result.stderr ); } + +#[tokio::test] +async fn find_print0_uses_nul_delimiters() { + let mut bash = Bash::builder().build(); + + bash.exec("mkdir -p /tmp/find_print0 && touch /tmp/find_print0/a.txt /tmp/find_print0/b.txt") + .await + .unwrap(); + + let result = bash + .exec("find /tmp/find_print0 -type f -print0") + .await + .unwrap(); + + assert_eq!(result.exit_code, 0); + assert!( + result.stdout.contains('\0'), + "Expected NUL-delimited output, got: {:?}", + result.stdout + ); + assert!( + !result.stdout.contains('\n'), + "-print0 must not emit newline-delimited output: {:?}", + result.stdout + ); +} + +#[tokio::test] +async fn find_not_negates_type_predicate() { + let mut bash = Bash::builder().build(); + + bash.exec("mkdir -p /tmp/find_not_type/subdir && touch /tmp/find_not_type/file.txt") + .await + .unwrap(); + + let result = bash + .exec("find /tmp/find_not_type -not -type f | sort") + .await + .unwrap(); + + assert_eq!(result.exit_code, 0); + assert!( + result.stdout.contains("/tmp/find_not_type/subdir"), + "Expected directories in negated type output, got: {:?}", + result.stdout + ); + assert!( + !result.stdout.contains("/tmp/find_not_type/file.txt"), + "Negated -type f must exclude files, got: {:?}", + result.stdout + ); +} + +#[tokio::test] +async fn find_dangling_not_fails_closed() { + let mut bash = Bash::builder().build(); + + bash.exec("mkdir -p /tmp/find_dangling_not && touch /tmp/find_dangling_not/file.txt") + .await + .unwrap(); + + let result = bash.exec("find /tmp/find_dangling_not -not").await.unwrap(); + + assert_ne!(result.exit_code, 0); + assert!( + result.stderr.contains("missing predicate after '-not'"), + "Expected fail-closed diagnostic, got: {:?}", + result.stderr + ); +}