Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/uu/head/src/head.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
25 changes: 25 additions & 0 deletions tests/by-util/test_head.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
29 changes: 28 additions & 1 deletion util/check-safe-traversal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ""
Expand Down
Loading