From 28cff684bdceab0f3080e83fcb68269faf765448 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 10 May 2026 12:24:28 +0200 Subject: [PATCH] head: fix -c0 and -n0 on directories to match GNU behavior When zero bytes or zero lines are requested, there is nothing to read, so we should not check whether the path is a directory. GNU head succeeds in this case because it never attempts to open/read the file. Previously, uutils head would stat the file and reject directories even when no reading was needed, causing a spurious 'Is a directory' error. Fixes #12215 --- src/uu/head/src/head.rs | 40 ++++++++++++++++++++++++-------- tests/by-util/test_head.rs | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/uu/head/src/head.rs b/src/uu/head/src/head.rs index 94f8563d9..896e0c923 100644 --- a/src/uu/head/src/head.rs +++ b/src/uu/head/src/head.rs @@ -447,15 +447,12 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { Ok(()) } else { - if Path::new(file).is_dir() { - show!(USimpleError::new( - 1, - translate!("head-error-reading-file", "name" => file.quote(), "err" => "Is a directory") - )); - continue; - } - let mut file_handle = match File::open(file) { - Ok(f) => f, + // Stat the path first so we know whether to print the header. + // GNU head prints "==> name <==" for existing files and + // directories, but NOT for nonexistent ones — those produce + // only an error message. + let metadata = match Path::new(file).metadata() { + Ok(m) => m, Err(err) => { show!(err.map_err_context( || translate!("head-error-cannot-open", "name" => file.quote()) @@ -470,7 +467,32 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { write!(stdout, "==> ")?; print_verbatim(file).unwrap(); writeln!(stdout, " <==")?; + first = false; } + // When 0 bytes or 0 lines are requested, there is nothing to + // read, so we should succeed on directories just like GNU head + // does. Skip opening the file entirely in that case (also + // avoids platform differences: on Windows, `File::open` on a + // directory fails with "Permission denied"). + let zero_output = matches!(options.mode, Mode::FirstBytes(0) | Mode::FirstLines(0)); + if metadata.is_dir() { + if !zero_output { + show!(USimpleError::new( + 1, + translate!("head-error-reading-file", "name" => file.quote(), "err" => "Is a directory") + )); + } + continue; + } + let mut file_handle = match File::open(file) { + Ok(f) => f, + Err(err) => { + show!(err.map_err_context( + || translate!("head-error-cannot-open", "name" => file.quote()) + )); + continue; + } + }; head_file(&mut file_handle, options)?; Ok(()) }; diff --git a/tests/by-util/test_head.rs b/tests/by-util/test_head.rs index b90ef42a6..8406c3d90 100644 --- a/tests/by-util/test_head.rs +++ b/tests/by-util/test_head.rs @@ -933,3 +933,50 @@ fn test_do_not_attempt_to_read_a_directory() { .fails_with_code(1) .stderr_contains("error reading '.'"); } + +/// Regression test for https://github.com/uutils/coreutils/issues/12215 +/// `head -c0 ` should succeed (nothing to read), matching GNU. +#[test] +fn test_zero_bytes_on_directory_succeeds() { + new_ucmd!().args(&["-c", "0", "."]).succeeds().no_output(); +} + +/// `head -n0 ` should also succeed. +#[test] +fn test_zero_lines_on_directory_succeeds() { + new_ucmd!().args(&["-n", "0", "."]).succeeds().no_output(); +} + +/// GNU `head` prints the `==> name <==` header for every file argument +/// (including directories) when invoked with multiple files. With non-zero +/// output, directories also produce an "Is a directory" error after the +/// header. +#[cfg(not(windows))] +#[test] +fn test_directory_header_with_multiple_files() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("d"); + at.write("f", "hello\n"); + ts.ucmd() + .args(&["-c", "5", "d", "f"]) + .fails_with_code(1) + .stdout_is("==> d <==\n\n==> f <==\nhello") + .stderr_contains("Is a directory"); +} + +/// With `-c 0` (zero output), the directory header is still printed but no +/// error is emitted. +#[cfg(not(windows))] +#[test] +fn test_directory_header_with_multiple_files_zero_output() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("d"); + at.write("f", "hello\n"); + ts.ucmd() + .args(&["-c", "0", "d", "f"]) + .succeeds() + .stdout_is("==> d <==\n\n==> f <==\n") + .no_stderr(); +}