shell(ls): decide operand errors with lstat, not the O_DIRECTORY errno
Problem
- The
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. - Every other open error is fatal, so a dangling or self-referencing symlink operand (
ENOENT,ELOOP) is reported as an error. GNU and BSD ls list the link by name, exit 0. - The operand went through the
-a/-Adotfile filter, sols .hiddenandls ./fprint nothing, exit 0. - Cause for all three:
ShellLsTask::run_from_thread_pool,src/runtime/shell/builtin/ls.rs:459.
Fix
- When the open fails, apply the rule coreutils uses for a command-line operand:
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. - Operands bypass the dotfile filter, which only applies to directory entries.
shell_lstatatininterpreter.rsmirrorsshell_statatso Windows operands get the same path rewrite as the open above.- Verified:
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 #39725. Alsobunshell.test.ts.
Supersedes #39725, which only fixes the f/x case. Its stat check sits inside the ENOTDIR arm, 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
- The
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. f is a regular file, dl -> nonexist, loop -> loop, sub is a directory, restricted is a mode 000 directory, symrestricted -> restricted.
| command | 1.4.0 | this branch | coreutils 8.32 |
|---|---|---|---|
ls f/x | 0 f/x | 1 ls: f/x: Not a directory | 2 cannot access 'f/x': Not a directory |
ls -l f/x | 0 ?????????? ? ? ? ? ? f/x | 1 same error | 2 same error |
ls f/x/y/z | 0 f/x/y/z | 1 Not a directory | 2 Not a directory |
ls dl | 1 No such file or directory | 0 dl | 0 dl |
ls -l dl | 1 | 0 lrwxrwxrwx ... dl | 0 lrwxrwxrwx ... dl -> nonexist |
ls loop | 1 Too many levels of symbolic links | 0 loop | 0 loop |
ls loop/x | 1 ELOOP | 1 ELOOP | 2 ELOOP |
ls .hidden | 0 (empty) | 0 .hidden | 0 .hidden |
ls ./f | 0 (empty) | 0 ./f | 0 ./f |
ls sub f/x | 0 sub:\na\nf/x | 1 sub:\na + error | 2 same |
ls restricted, ls symrestricted, ls restricted/x | 1 Permission denied | 1 Permission denied | 2 Permission denied |
ls f, ls symf, ls symdir, ls nx, ls -d f | unchanged | unchanged | match |
The first version of this branch decided with lstat alone. Review caught that a symlink to an unreadable directory then passed as "exists, not a directory" and swallowed the EACCES. The stat first, lstat on ENOENT/ELOOP order is what coreutils does (gobble_file, DEREF_COMMAND_LINE_SYMLINK_TO_DIR), and the permission test now covers the symlink.
Not changed here:
- The exit code stays 1. The builtin uses 1 for every error, coreutils uses 2 for a failed operand and 1 for a failed entry.
-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 symlink tests 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 denied tests fail in any environment that runs as root, with or without this change (#39232). They pass here as nobody.
cargo check -p bun_runtime --target x86_64-pc-windows-msvc passes.
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