diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 4e4155c67d4..6d442d3c729 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -317,6 +317,33 @@ fn ensure_is_directory(path: &Path) -> IOResult<()> { } } +/// Validates path components preceding each `..` component +/// +/// Ensures that the accumulated path is a directory before traversing +/// to its parent. This prevents paths containing a regular file followed +/// by `..` from being incorrectly accepted after logical normalization.. +fn validate_parent_component(path: &Path) -> IOResult<()> { + let mut current = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + current.push(component.as_os_str()); + } + + Component::CurDir => {} + + Component::ParentDir => { + if !current.as_os_str().is_empty() { + ensure_is_directory(¤t)?; + } + current.pop(); + } + } + } + Ok(()) +} + /// Return the canonical, absolute form of a path. /// /// This function is a generalization of [`std::fs::canonicalize`] that @@ -362,6 +389,9 @@ pub fn canonicalize>( dunce::canonicalize(current_dir)?.join(original) }; let path = if res_mode == ResolveMode::Logical { + if miss_mode != MissingHandling::Missing { + validate_parent_component(&original)?; + } normalize_path(&original) } else { original @@ -380,6 +410,9 @@ pub fn canonicalize>( } OwningComponent::CurDir => {} OwningComponent::ParentDir => { + if !result.as_os_str().is_empty() && miss_mode != MissingHandling::Missing { + ensure_is_directory(&result)?; + } result.pop(); } } diff --git a/tests/by-util/test_readlink.rs b/tests/by-util/test_readlink.rs index c374515df6e..e3cd7905e3c 100644 --- a/tests/by-util/test_readlink.rs +++ b/tests/by-util/test_readlink.rs @@ -563,3 +563,20 @@ fn test_verbose_or_silent() { ) .no_stdout(); } +#[test] +fn test_readlink_canonicalize_regular_file_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("regular_file"); + + scene + .ucmd() + .arg("-v") + .arg("-f") + .arg("regular_file/..") + .fails() + .code_is(1) + .stderr_contains(NOT_A_DIRECTORY) + .no_stdout(); +} diff --git a/tests/by-util/test_realpath.rs b/tests/by-util/test_realpath.rs index 0a2b6b17214..37c7b55b1a3 100644 --- a/tests/by-util/test_realpath.rs +++ b/tests/by-util/test_realpath.rs @@ -16,6 +16,11 @@ use std::path::{MAIN_SEPARATOR, Path}; static GIBBERISH: &str = "supercalifragilisticexpialidocious"; +#[cfg(not(windows))] +static NOT_A_DIRECTORY: &str = "Not a directory"; +#[cfg(windows)] +static NOT_A_DIRECTORY: &str = "The directory name is invalid."; + #[test] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_realpath_current_directory() { @@ -670,3 +675,153 @@ fn test_realpath_canonicalize_vs_existing() { } } } + +#[test] +fn test_realpath_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("regular_file"); + + scene + .ucmd() + .arg("regular_file/..") + .fails() + .code_is(1) + .stderr_contains(NOT_A_DIRECTORY) + .no_stdout(); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_realpath_directory_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("directory"); + + scene + .ucmd() + .arg("directory/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_realpath_directory_followed_by_parent_dir_missing_mode() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + scene + .ucmd() + .arg("-m") + .arg("missing/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_realpath_strip_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("directory"); + at.touch("regular_file"); + + // With `-s` (no symlink resolution), GNU still validates that the + // component preceding `..` is an existing directory. + scene + .ucmd() + .arg("-s") + .arg("directory/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); + + scene + .ucmd() + .arg("-s") + .arg("regular_file/..") + .fails() + .code_is(1) + .stderr_contains(NOT_A_DIRECTORY) + .no_stdout(); + + scene + .ucmd() + .arg("-s") + .arg("missing/..") + .fails() + .code_is(1) + .stderr_contains("No such file or directory") + .no_stdout(); + + // `-m` relaxes the directory/existence check, so these succeed even + // combined with `-s`. + scene + .ucmd() + .args(&["-s", "-m"]) + .arg("regular_file/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); + + scene + .ucmd() + .args(&["-s", "-m"]) + .arg("missing/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_realpath_missing_mode_regular_file_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("regular_file"); + + // `-m` permits missing and non-directory components, so a regular file + // followed by `..` must succeed (matching GNU). + scene + .ucmd() + .arg("-m") + .arg("regular_file/..") + .succeeds() + .stdout_is(format!("{}\n", at.root_dir_resolved())); +} + +#[test] +fn test_realpath_logical_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("regular_file"); + + scene + .ucmd() + .arg("-L") + .arg("regular_file/..") + .fails() + .code_is(1) + .stderr_contains(NOT_A_DIRECTORY) + .no_stdout(); +} + +#[test] +fn test_realpath_existing_followed_by_parent_dir() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("regular_file"); + + scene + .ucmd() + .arg("-e") + .arg("regular_file/..") + .fails() + .code_is(1) + .stderr_contains(NOT_A_DIRECTORY) + .no_stdout(); +}