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
This commit is contained in:
Sylvestre Ledru
2026-05-10 12:24:28 +02:00
committed by Daniel Hofstetter
parent 0b04f5ec69
commit 28cff684bd
2 changed files with 78 additions and 9 deletions
+31 -9
View File
@@ -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(())
};
+47
View File
@@ -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 <directory>` 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 <directory>` 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();
}