From 4f58b49d26d34982758c3be49355fefb5ff12b19 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sun, 16 Aug 2026 12:31:37 -0700 Subject: [PATCH 1/2] find: add -warn and -nowarn options GNU find distinguishes warnings about inadvisable command-line usage from errors encountered while traversing directories. These warnings do not change find's exit status. By default, GNU enables them only when standard input is a terminal and POSIXLY_CORRECT is unset; otherwise it disables them so existing scripts do not gain unsolicited diagnostics. The -warn and -nowarn options change that state at the point where each appears. Thus `-warn -type d -maxdepth 1` warns when parsing reaches -maxdepth, while placing -nowarn before -maxdepth suppresses the warning. Putting -nowarn at the end cannot retract a warning already emitted. GNU leaves the active warnings unspecified when POSIXLY_CORRECT and an explicit -warn are both present. Global options such as -maxdepth are semantically non-positional: they affect tests before and after their location. GNU accepts a global option after a test or action, but warns that the ordering is misleading when warnings are enabled. uutils previously rejected -warn and -nowarn as unknown predicates. Track the current warning state and most recent test or action, accept both options, and implement GNU's misplaced-global-option diagnostic for the supported global options. Do not issue that diagnostic for help or version options: they terminate parsing instead of affecting surrounding tests, so the diagnostic's explanation would be false. GNU also controls warnings for deprecated -d and slashes in -name or -iname patterns; those remain outside this change. Adding -warn also enables an external compatibility test which exposes an unsafe interaction with -execdir and -okdir. Those actions change directory before searching PATH for the requested executable, so a relative or empty PATH entry can select a different program in each visited directory. Reject non-absolute PATH entries when parsing either action. Add coverage for enabling the diagnostic, disabling it before the global option is parsed, excluding the terminating help and version options, and rejecting relative PATH entries for directory-local actions. --- src/find/matchers/exec.rs | 33 ++++++++++++++--- src/find/matchers/mod.rs | 74 ++++++++++++++++++++++++++++++++++++--- src/find/mod.rs | 13 +++++-- tests/test_find.rs | 41 ++++++++++++++++++++++ 4 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/find/matchers/exec.rs b/src/find/matchers/exec.rs index c061e260..a97ae84d 100644 --- a/src/find/matchers/exec.rs +++ b/src/find/matchers/exec.rs @@ -5,6 +5,7 @@ // https://opensource.org/licenses/MIT. use std::cell::RefCell; +use std::env; use std::error::Error; use std::ffi::OsString; use std::io::{stderr, Write}; @@ -27,6 +28,22 @@ fn parse_arg(s: &str) -> Arg { } } +fn validate_execdir_path() -> Result<(), Box> { + let Some(path) = env::var_os("PATH") else { + return Err("PATH is not set; -execdir and -okdir require an absolute search path".into()); + }; + + if let Some(entry) = env::split_paths(&path).find(|entry| !entry.is_absolute()) { + return Err(format!( + "relative PATH entry {} is insecure with -execdir and -okdir", + entry.display() + ) + .into()); + } + + Ok(()) +} + pub struct SingleExecMatcher { executable: Arg, args: Vec, @@ -40,7 +57,7 @@ impl SingleExecMatcher { args: &[&str], exec_in_parent_dir: bool, ) -> Result> { - Ok(Self::new_impl(executable, args, exec_in_parent_dir, false)) + Self::new_impl(executable, args, exec_in_parent_dir, false) } pub fn new_interactive( @@ -48,7 +65,7 @@ impl SingleExecMatcher { args: &[&str], exec_in_parent_dir: bool, ) -> Result> { - Ok(Self::new_impl(executable, args, exec_in_parent_dir, true)) + Self::new_impl(executable, args, exec_in_parent_dir, true) } fn new_impl( @@ -56,15 +73,18 @@ impl SingleExecMatcher { args: &[&str], exec_in_parent_dir: bool, interactive: bool, - ) -> Self { + ) -> Result> { + if exec_in_parent_dir { + validate_execdir_path()?; + } let transformed_args = args.iter().map(|&a| parse_arg(a)).collect(); - Self { + Ok(Self { executable: parse_arg(executable), args: transformed_args, exec_in_parent_dir, interactive, - } + }) } } @@ -158,6 +178,9 @@ impl MultiExecMatcher { args: &[&str], exec_in_parent_dir: bool, ) -> Result> { + if exec_in_parent_dir { + validate_execdir_path()?; + } let transformed_args = args.iter().map(OsString::from).collect(); Ok(Self { diff --git a/src/find/matchers/mod.rs b/src/find/matchers/mod.rs index 0206a974..1fcf6692 100644 --- a/src/find/matchers/mod.rs +++ b/src/find/matchers/mod.rs @@ -443,6 +443,62 @@ fn get_or_create_file(path: &str) -> Result> { Ok(file) } +fn is_global_option(argument: &str) -> bool { + matches!( + argument, + "-d" | "-depth" + | "-files0-from" + | "-help" + | "--help" + | "-maxdepth" + | "-mindepth" + | "-mount" + | "-xdev" + | "-noleaf" + | "-sorted" + | "-version" + | "--version" + ) +} + +fn is_testing_criterion(argument: &str) -> bool { + !is_global_option(argument) + && !matches!( + argument, + "-daystart" + | "-follow" + | "-regextype" + | "-warn" + | "-nowarn" + | "-not" + | "!" + | "-and" + | "-a" + | "-or" + | "-o" + | "," + | "(" + | ")" + ) +} + +fn is_warnable_global_option(argument: &str) -> bool { + is_global_option(argument) && !matches!(argument, "-help" | "--help" | "-version" | "--version") +} + +fn warn_if_global_option_follows_test(argument: &str, config: &Config) { + if config.warnings_enabled && is_warnable_global_option(argument) { + if let Some(previous) = &config.last_non_option { + eprintln!( + "find: warning: you have specified the global option {argument} after the argument \ + {previous}, but global options are not positional, i.e., {argument} \ + affects tests specified before it as well as those specified after it. Please \ + specify global options before other arguments." + ); + } + } +} + /// The main "translate command-line args into a matcher" function. Will call /// itself recursively if it encounters an opening bracket. A successful return /// consists of a tuple containing the new index into the args array to use (if @@ -464,7 +520,9 @@ fn build_matcher_tree( let mut i = arg_index; let mut invert_next_matcher = false; while i < args.len() { - let possible_submatcher = match args[i] { + let argument = args[i]; + warn_if_global_option_follows_test(argument, config); + let possible_submatcher = match argument { "-print" => Some(Printer::new(PrintDelimiter::Newline, None).into_box()), "-print0" => Some(Printer::new(PrintDelimiter::Null, None).into_box()), "-printf" => { @@ -903,17 +961,14 @@ fn build_matcher_tree( Some(TrueMatcher.into_box()) } "-d" | "-depth" => { - // TODO add warning if it appears after actual testing criterion config.depth_first = true; Some(TrueMatcher.into_box()) } "-mount" | "-xdev" => { - // TODO add warning if it appears after actual testing criterion config.same_file_system = true; Some(TrueMatcher.into_box()) } "-sorted" => { - // TODO add warning if it appears after actual testing criterion config.sorted_output = true; Some(TrueMatcher.into_box()) } @@ -941,6 +996,14 @@ fn build_matcher_tree( config.version_requested = true; None } + "-warn" => { + config.warnings_enabled = true; + Some(TrueMatcher.into_box()) + } + "-nowarn" => { + config.warnings_enabled = false; + Some(TrueMatcher.into_box()) + } "-files0-from" => { if i >= args.len() - 1 { return Err(From::from(format!("missing argument to {}", args[i]))); @@ -1002,6 +1065,9 @@ fn build_matcher_tree( top_level_matcher.new_and_condition(submatcher); } } + if is_testing_criterion(argument) { + config.last_non_option = Some(argument.to_string()); + } } if expecting_bracket { return Err(From::from( diff --git a/src/find/mod.rs b/src/find/mod.rs index 5905a8b3..8a8e046e 100644 --- a/src/find/mod.rs +++ b/src/find/mod.rs @@ -8,8 +8,8 @@ pub mod matchers; use matchers::{Follow, WalkEntry}; use std::cell::RefCell; +use std::env; use std::error::Error; -#[cfg(unix)] use std::io::IsTerminal; use std::io::{self, stderr, stdout, BufRead, BufReader, Write}; use std::path::PathBuf; @@ -27,6 +27,8 @@ pub struct Config { version_requested: bool, today_start: bool, no_leaf_dirs: bool, + warnings_enabled: bool, + last_non_option: Option, follow: Follow, files0_argument: Option, } @@ -46,6 +48,8 @@ impl Default for Config { // and this configuration field will exist as // a compatibility item for GNU findutils. no_leaf_dirs: false, + warnings_enabled: false, + last_non_option: None, follow: Follow::Never, files0_argument: None, // This option exclusively for -files0-from argument. } @@ -202,7 +206,11 @@ impl Iterator for Files0Paths { fn parse_args(args: &[&str]) -> Result> { let mut paths = vec![]; let mut i = 0; - let mut config = Config::default(); + let mut config = Config { + warnings_enabled: std::io::stdin().is_terminal() + && env::var_os("POSIXLY_CORRECT").is_none(), + ..Config::default() + }; while i < args.len() { match args[i] { @@ -399,6 +407,7 @@ Positional options: -sorted Sort directory contents by name (non-standard) -regextype type Set regex syntax (default: emacs) -files0-from file Read starting points from file, NUL-separated + -warn / -nowarn Turn warning messages on or off Tests: -name pattern Base name matches shell pattern diff --git a/tests/test_find.rs b/tests/test_find.rs index e5e2da9d..dc594c5c 100644 --- a/tests/test_find.rs +++ b/tests/test_find.rs @@ -97,6 +97,36 @@ fn two_matchers_one_matches() { .no_output(); } +#[test] +fn warns_when_global_option_follows_test() { + ucmd() + .args(&["-warn", "-type", "d", "-maxdepth", "0"]) + .succeeds() + .stdout_is(".\n") + .stderr_is( + "find: warning: you have specified the global option -maxdepth after the argument \ + -type, but global options are not positional, i.e., -maxdepth affects tests \ + specified before it as well as those specified after it. Please specify global \ + options before other arguments.\n", + ); + + ucmd() + .args(&["-warn", "-type", "d", "-nowarn", "-maxdepth", "0"]) + .succeeds() + .stdout_only(".\n") + .no_stderr(); +} + +#[test] +fn does_not_warn_for_help_or_version_after_test() { + for option in ["-help", "--help", "-version", "--version"] { + ucmd() + .args(&["-warn", "-type", "d", option]) + .succeeds() + .no_stderr(); + } +} + #[test] fn multiple_matcher_success() { ucmd() @@ -1364,6 +1394,17 @@ fn find_ok_missing_semicolon() { .no_stdout(); } +#[test] +fn find_directory_exec_rejects_relative_path_entries() { + for action in ["-execdir", "-okdir"] { + ucmd() + .env("PATH", "relative") + .args(&[".", action, "echo", "{}", ";"]) + .fails() + .stderr_contains("relative PATH entry"); + } +} + #[test] fn version_write_error_is_handled() { use std::cell::RefCell; From 5aef7b1c1cd4b1e6de64071c92701b15e67ea6f7 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Tue, 18 Aug 2026 16:42:13 -0700 Subject: [PATCH 2/2] find: document what a "global option" is Address PR feedback asking for clarification on is_global_option. --- src/find/matchers/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/find/matchers/mod.rs b/src/find/matchers/mod.rs index c49bcfdb..d1f3d837 100644 --- a/src/find/matchers/mod.rs +++ b/src/find/matchers/mod.rs @@ -447,6 +447,12 @@ fn get_or_create_file(path: &str) -> Result> { Ok(file) } +/// A "global option" (GNU find's term) applies to the whole command instead +/// of a specific test, so unlike a test it takes effect no matter where in +/// the expression it appears; `-maxdepth` after `-type f`, for instance, +/// still limits the depth of every path find visits, not just descendants of +/// paths matching `-type f`. GNU find warns when one appears after a test, as +/// implemented by `warn_if_global_option_follows_test` below. fn is_global_option(argument: &str) -> bool { matches!( argument,