Repository navigation
shell(ls): decide operand errors with lstat, not the O_DIRECTORY errno - #40091
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review. WalkthroughChanges
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:59 AM PT - Aug 22nd, 2026
✅ @robobun, your commit 6517b29370e6b23f8af7fd6f91642ea3c98371df passed in 🧪 To try this PR locally: bunx bun-pr 40091That installs a local version of the PR into your bun-40091 --bun |
4912b1d to
e8045bb
Compare
e8045bb to
e6b6dcf
Compare
The ls builtin opened each operand with O_DIRECTORY and read the failure as: ENOTDIR means the operand is a file, anything else is an error. That is wrong in two directions. open(O_DIRECTORY) also returns ENOTDIR when a path component is a regular file, so `ls f/x` printed `f/x` with exit 0 (and `ls -l f/x` printed a row of `?`). A dangling or looping symlink operand fails with ENOENT or ELOOP, so `ls dl` reported an error where GNU and BSD ls list the link by name. On open failure, apply the coreutils rule for a command-line operand: stat it, and on ENOENT or ELOOP fall back to lstat. A directory that would not open keeps the open error (EACCES, also through a symlink). A failed stat is the error to report. Anything else that exists is a non-directory and is listed by name, described by lstat so a symlink shows as a link. Operands also went through the -a/-A dotfile filter, so `ls .hidden` and `ls ./f` printed nothing with exit 0. Operands now skip that filter, as in coreutils. shell_lstatat mirrors shell_statat so Windows operands get the same path rewrite as the open.
e6b6dcf to
b39ca10
Compare
There was a problem hiding this comment.
LGTM — the earlier symlink-to-unreadable-directory regression is fixed and now covered by ls link-to-restricted in the permission test.
What was reviewed:
- Traced
list_non_directory_operandthrough each row of the conformance matrix (f/x,dl,loop,loop/x,.hidden,restricted,symrestricted, plain file) — the stat→lstat order routes each to the coreutils outcome. - Confirmed the
Ok(_)fall-through tolstatis needed so-lon a symlink-to-file shows the link, and thatadd_entry_short/add_entry_long_from_statare pure extractions of the previous inline code. shell_lstatatmirrorsshell_statatbyte-for-byte withlstat/lstatat;bun_sys::lstatexists for the Windows arm.- The two flipped
broken symlinktests were pinning accidental behavior from #20531; new expectations match GNU/BSD ls.
Extended reasoning...
Overview
The PR fixes three bugs in the shell ls builtin's handling of command-line operands, all rooted in ShellLsTask::run_from_thread_pool interpreting the open(O_DIRECTORY) errno as the sole classifier: (1) ENOTDIR from a non-directory path component was treated the same as ENOTDIR from a file operand, so ls f/x succeeded; (2) any non-ENOTDIR error was fatal, so dangling/looping symlinks errored instead of being listed; (3) file operands went through the -a/-A filter, so ls .hidden and ls ./f printed nothing. The fix replaces the errno switch with the coreutils operand rule (stat, then lstat on ENOENT/ELOOP), factored into list_non_directory_operand. Supporting refactors extract add_entry_short and add_entry_long_from_stat so the operand path can bypass should_skip_entry and reuse an already-obtained Stat. shell_lstatat is added alongside shell_statat in interpreter.rs for Windows path rewriting parity.
Security risks
None. The change only affects how ls classifies and reports its own operands; no new inputs are trusted, no allocations are sized from untrusted data, and the added syscalls (stat/lstat) are read-only metadata queries on paths the user already passed. The one direction that could have weakened error reporting — a symlink to an unreadable directory silently succeeding — was caught in the first review round and is now fixed (stat sees S_IFDIR through the link, so the open's EACCES is reported) and pinned by the link-to-restricted test case.
Level of scrutiny
Medium. This is a self-contained behavioral fix in one shell builtin with no memory-safety surface change (no new ownership, the ZBox::from_bytes clone matches the pre-PR pattern, and the &Stat refactor is a trivial by-ref conversion). The user-visible behavior change — dangling symlinks now succeed, f/x now fails — is a correctness fix bringing the builtin in line with GNU and BSD ls, backed by a detailed conformance table in the PR body. The two existing tests whose expectations flip are documented as having pinned accidental behavior from a memory refactor (#20531), which is the right call under REVIEW.md's "never mutate an existing test" rule since the original assertions encoded a bug rather than a contract.
Other factors
An earlier revision decided with lstat alone; my prior review flagged that this swallowed EACCES on a symlink to a mode-000 directory. The author fixed it in e6b6dcf by adding the stat-first step and a regression case in the permission denied directory test. All comment-cop feedback (long comments) has been trimmed. The bug hunting system found no issues on the current revision. Test coverage is thorough across the variant matrix (short and -l listings, f/x at multiple depths, mixed operands, loop vs loop/x, .hidden and ./f), and the Windows arm was compile-checked. The remaining coreutils divergences (exit code 1 vs 2, no -> target in -l, -l/-d still following symlink-to-dir) are explicitly scoped out and unchanged from before this PR.
…cases cargo clippy denies needless_pass_by_value on the open error argument of list_non_directory_operand. Take a reference. Add the two operand cases from #39725 that this branch did not cover: `ls -d f/x` and `ls f f/x` (a file operand next to an operand whose parent is a file).
|
6517b29 does two things:
|
There was a problem hiding this comment.
LGTM — the earlier finding (symlink to an unreadable directory swallowing EACCES) is fixed: list_non_directory_operand now stats first and only falls back to lstat on ENOENT/ELOOP, and the permission denied directory test covers link-to-restricted.
What was reviewed:
- Traced
list_non_directory_operandfor each row of the conformance matrix (f/x, dangling link, loop,loop/x,.hidden,./f,symrestricted, plain file, nonexistent) — each reaches the intended arm. add_entry_short/add_entry_long_from_statextractions are behavior-preserving; operand path now bypassesshould_skip_entrywhile directory entries still go through it.shell_lstatatmirrorsshell_statatexactly (same Windows path rewrite); the non-isPosix-gated ENOTDIR test only asserts the message prefix on Windows.- The two flipped
broken symlinktests now expect coreutils behavior; they were pinning the pre-fix error.
Extended reasoning...
Overview
The PR reworks how the shell ls builtin handles a command-line operand when openat(O_DIRECTORY) fails. Previously the code branched on the open errno alone: ENOTDIR → print the operand as a file (wrong for f/x), anything else → error (wrong for dangling/looping symlinks). It also routed the operand through add_entry, which applies the -a/-A dotfile filter meant for directory entries, so ls .hidden and ls ./f printed nothing.
The new list_non_directory_operand implements the coreutils rule: stat the operand; if the target is a directory, keep the open error (covers EACCES through a symlink); if stat itself fails with anything other than ENOENT/ELOOP, report that (covers f/x → ENOTDIR); otherwise lstat and list the operand by name (covers dangling links, self-loops, regular files, symlinks-to-files). The listing goes straight to add_entry_short/add_entry_long_from_stat, so the dotfile filter no longer applies to operands. shell_lstatat is added alongside shell_statat so Windows gets the same POSIX-path rewrite as the open call above it.
The rest of the diff is mechanical extraction of add_entry_short and add_entry_long_from_stat from their inlined bodies, plus tests.
Prior review round
I flagged one regression on the first revision: a symlink to a mode-000 directory would lstat as S_IFLNK, pass !ISDIR, and be listed with exit 0, discarding the EACCES. The author fixed this exactly as suggested — stat first, lstat only on ENOENT/ELOOP — and added ls link-to-restricted to the permission test. I re-traced the fixed function against every case in the PR's conformance table plus the regression case and each hits the intended arm. All comment-cop threads about long comments were also addressed; every added comment is now one line.
Security risks
None. This is output-formatting logic in a shell builtin; no untrusted parsing, no privilege boundaries, no allocation driven by external size fields. The change moves error handling toward failing correctly (f/x now errors instead of silently succeeding) rather than away from it.
Level of scrutiny
Medium. The stat/lstat decision tree is subtle — it took one round to get right — but the function is small, self-contained, matches the documented coreutils rule (gobble_file, DEREF_COMMAND_LINE_SYMLINK_TO_DIR), and every branch in the matrix has a test. The two existing tests whose expectations flipped were pinning the incorrect pre-fix behavior (introduced in a memory refactor, not as a deliberate spec), and the PR body calls this out explicitly.
Other factors
Test coverage is thorough: f/x, f/x/y/z, -l f/x, -d f/x, mixed good/bad operands, dangling link (short and -l), self-loop and loop/x, .hidden, -d .hidden, ./f, -l ./f, and the symlink-to-restricted regression. The Windows-visible test relaxes only the strerror text, not the exit code or path echo. The refactored helpers are byte-for-byte moves; stat_mtime(&stat) → stat_mtime(stat) is the only signature-driven edit. Known gaps (exit code 1 vs 2, no -> target in -l, -l/-d still following symlink-to-dir) are called out in the PR body as out of scope and are pre-existing.
shell_lstatat (#40091) landed after this branch and calls shell_get_path, whose signature this PR changes. Without this the Windows build fails with E0061 after a merge with main.
Problem
lsbuiltin opens each operand withO_DIRECTORYand readsENOTDIRas "the operand is a file". The kernel also returnsENOTDIRwhen a path component is a file, sols f/xprintsf/xand exits 0, andls -l f/xprints a row of?. coreutils:ls: cannot access 'f/x': Not a directory, exit 2.ENOENT,ELOOP) is reported as an error. GNU and BSD ls list the link by name, exit 0.-a/-Adotfile filter, sols .hiddenandls ./fprint nothing, exit 0.ShellLsTask::run_from_thread_pool,src/runtime/shell/builtin/ls.rs:459.Fix
statit, and onENOENTorELOOPfall back tolstat. A directory that would not open keeps the open error (EACCES, also through a symlink). A failedstatis the error to report (f/x,nx). Anything else that exists is a non-directory and is listed by name.lstatdescribes it, so-lshows a symlink as a link.shell_lstatatininterpreter.rsmirrorsshell_statatso Windows operands get the same path rewrite as the open above.test/js/bun/shell/commands/ls.test.ts(5 new or updated tests fail on the release build, plus a symlink case in the permission test). Thef/xtest also carries thels -d f/xandls f f/xcases from shell(ls): report an operand whose parent is a file instead of listing it #39725. Alsobunshell.test.ts.Supersedes #39725, which only fixes the
f/xcase. Itsstatcheck sits inside theENOTDIRarm, so it cannot list a dangling link, and it keeps the dotfile filter on operands. Its four test cases pass on this branch. The two this branch did not already have are folded in.Background
lsbuiltin runs oneShellLsTaskper operand on the thread pool. A task either lists a directory's entries or appends the operand itself to its output. A task that setserrmakes the builtin printls: <path>: <strerror>and exit 1.lstatreports a symlink as itself,statreports its target. A dangling link exists forlstatand does not exist forstat.Notes
Cells from the conformance matrix, release 1.4.0 vs this branch vs
bash -c, run as a non-root user.fis a regular file,dl -> nonexist,loop -> loop,subis a directory,restrictedis a mode 000 directory,symrestricted -> restricted.ls f/xf/xls: f/x: Not a directorycannot access 'f/x': Not a directoryls -l f/x?????????? ? ? ? ? ? f/xls f/x/y/zf/x/y/zNot a directoryNot a directoryls dlNo such file or directorydldlls -l dllrwxrwxrwx ... dllrwxrwxrwx ... dl -> nonexistls loopToo many levels of symbolic linkslooploopls loop/xls .hidden.hidden.hiddenls ./f./f./fls sub f/xsub:\na\nf/xsub:\na+ errorls restricted,ls symrestricted,ls restricted/xPermission deniedPermission deniedPermission deniedls f,ls symf,ls symdir,ls nx,ls -d fThe first version of this branch decided with
lstatalone. Review caught that a symlink to an unreadable directory then passed as "exists, not a directory" and swallowed theEACCES. Thestatfirst,lstatonENOENT/ELOOPorder is what coreutils does (gobble_file,DEREF_COMMAND_LINE_SYMLINK_TO_DIR), and the permission test now covers the symlink.Not changed here:
-ldoes not print-> targetfor symlinks.ls -l symdirandls -d symdirstill follow a command-line symlink to a directory. coreutils does not with-lor-d.The two existing
broken symlinktests expected the error. They came in with #20531 (a memory refactor) and pinned the behavior of the time. They now expect the coreutils output.The chmod based
permission deniedtests fail in any environment that runs as root, with or without this change (#39232). They pass here asnobody.cargo check -p bun_runtime --target x86_64-pc-windows-msvcpasses.no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/commands/ls.test.ts