From 8ce18d4c4fa590b1be77d9ea46b9bb2a04d709c4 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 5 Jun 2026 13:15:48 +0200 Subject: [PATCH] head: add TOCTOU regression tests and fix comment typo Follow-up to the metadata TOCTOU fix (#11972, PR #12439): - Add a syscall-level regression guard in util/check-safe-traversal.sh: under strace, head must read metadata from the open descriptor (fstat/statx on the fd) and must not stat the path before opening it. Verified this fails on the pre-fix code and passes after. - Add an integration test asserting that an unreadable file produces an error but no "==> name <==" header, matching GNU (the header is only printed after a successful open). - Fix a stray paren in the Windows-branch comment. --- src/uu/head/src/head.rs | 2 +- tests/by-util/test_head.rs | 25 +++++++++++++++++++++++++ util/check-safe-traversal.sh | 29 ++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/uu/head/src/head.rs b/src/uu/head/src/head.rs index d5895a8a7f6..e8e09ea0007 100644 --- a/src/uu/head/src/head.rs +++ b/src/uu/head/src/head.rs @@ -471,7 +471,7 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { Ok(f) => f, Err(err) => { #[cfg(windows)] - // On Windows, `File::open` on a directory fails with "Permission denied"). + // On Windows, `File::open` on a directory fails with "Permission denied". if err.kind() == io::ErrorKind::PermissionDenied { if let Ok(m) = Path::new(file).metadata() { if m.is_dir() { diff --git a/tests/by-util/test_head.rs b/tests/by-util/test_head.rs index 8406c3d9082..570bea45a38 100644 --- a/tests/by-util/test_head.rs +++ b/tests/by-util/test_head.rs @@ -980,3 +980,28 @@ fn test_directory_header_with_multiple_files_zero_output() { .stdout_is("==> d <==\n\n==> f <==\n") .no_stderr(); } + +/// GNU `head` prints the `==> name <==` header only after the file is +/// successfully opened. A file that exists but cannot be opened (e.g. no read +/// permission) must therefore produce only an error and no header. +#[cfg(unix)] +#[test] +fn test_unreadable_file_prints_no_header() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.write("unreadable", "secret\n"); + at.write("readable", "hello\n"); + at.set_mode("unreadable", 0o000); + + // Running as root bypasses permission checks, so the open would succeed. + if std::fs::File::open(at.plus("unreadable")).is_ok() { + return; + } + + ts.ucmd() + .args(&["-c", "5", "unreadable", "readable"]) + .fails_with_code(1) + .stdout_is("==> readable <==\nhello") + .stdout_does_not_contain("==> unreadable <==") + .stderr_contains("cannot open 'unreadable' for reading: Permission denied"); +} diff --git a/util/check-safe-traversal.sh b/util/check-safe-traversal.sh index d64e6ea4cff..79ef1b633ae 100755 --- a/util/check-safe-traversal.sh +++ b/util/check-safe-traversal.sh @@ -191,7 +191,7 @@ if [ "$USE_MULTICALL" -eq 1 ]; then AVAILABLE_UTILS=$($COREUTILS_BIN --list) else AVAILABLE_UTILS="" - for util in rm chmod chown chgrp du mv cp touch; do + for util in rm chmod chown chgrp du mv cp touch head; do if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then AVAILABLE_UTILS="$AVAILABLE_UTILS $util" fi @@ -409,6 +409,33 @@ if echo "$AVAILABLE_UTILS" | grep -q "touch"; then echo "✓ touch creates with O_CREAT and without O_TRUNC" fi +# Test head - the is-a-directory check must derive from the already-open +# descriptor (fstat/statx on the fd), not from a separate path-based stat +# performed before the open. A path stat followed by an open is a TOCTOU +# window (#11972): the object named by the path can be swapped in between. +if echo "$AVAILABLE_UTILS" | grep -q "head"; then + echo "" + echo "Testing head (fstat_after_open)..." + if [ "$USE_MULTICALL" -eq 1 ]; then + head_cmd="$COREUTILS_BIN head" + else + head_cmd="$PROJECT_ROOT/target/${PROFILE}/head" + fi + echo "headtest" > test_head_file.txt + strace -f -e trace=openat,fstat,newfstatat,statx,stat,lstat \ + -o strace_head_metadata.log $head_cmd -c 4 test_head_file.txt 2>/dev/null || true + cat strace_head_metadata.log + if ! grep -q 'openat(AT_FDCWD, "test_head_file.txt"' strace_head_metadata.log; then + fail_immediately "head did not open test_head_file.txt via openat" + fi + # The filename should appear only in the openat; any stat-family call naming + # the path means head stat'd it before opening - the TOCTOU window. + if grep '"test_head_file.txt"' strace_head_metadata.log | grep -qv 'openat('; then + fail_immediately "head stat'd the path before opening it - TOCTOU window (#11972); metadata must come from the open descriptor" + fi + echo "✓ head reads metadata from the open descriptor, not a path stat" +fi + echo "" echo "✓ Basic safe traversal verification completed" echo ""