pack: skip bins reached through symlinks; publish: do not read the readme through a symlink - #38707
pack: skip bins reached through symlinks; publish: do not read the readme through a symlink#38707robobun wants to merge 8 commits into
Conversation
…rectory The tree walk never packs symlinks (matching npm-packlist), but bin and directories.bin entries bypass the walk and were opened directly, so a symlinked bin was packed with the contents of its target, including targets outside the package. lstat every component of a bin path and only force-add it when it is a regular file (or a real directory for directories.bin) reached through real directories. A bin entry that names a directory is now ignored instead of failing the pack with EISDIR, which is also what npm does.
The tarball never contains a README that is a symlink, so the workspace readme scan must only consider regular files too; otherwise the link target (possibly outside the package) is sent to the registry as the package readme.
|
Warning Review limit reached
Next review available in: 34 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status: ready for review; CI is green on every lane that exercised this change. Reproduced on 1.4.0-canary with a package whose Tests: the new cases in |
There was a problem hiding this comment.
I reviewed this PR and didn't find bugs. Since it adds new symlink-checking path logic in pack (security-adjacent — deciding what leaves the package) and the Windows path is acknowledged as not yet effective, a human look would still be worthwhile.
Checked: the component-by-component lstatat loop terminates on empty/leading-slash/double-slash inputs; bin paths are already Posix-normalized by get_package_bins before reaching the new helper; is_package_bin only sets the exec bit and does not cause the tree walk to drop a skipped bin; the find_workspace_readme DT_UNKNOWN concern was examined and ruled out as consistent with tarball behavior.
Extended reasoning...
Overview
Two changes to bun pm pack / bun publish:
pack_command.rs: newbin_kind_without_following_symlinks()lstats each/-separated component of a bin path relative to the package root, and thefor bin in &binsloop now skips any bin that isn't a real file (or real directory fordirectories.bin) reached through real directories. This closes a hole where a bin that is a symlink, or sits behind a symlinked directory, would be packed with the contents of the link target (possibly outside the package). It also fixesbinnaming a directory, which previously failed pack with EISDIR.publish_command.rs:find_workspace_readmenow requiresentry.kind == Fileinstead of!= Directory, so a symlinked README isn't read through and sent as registry metadata when the tarball itself omits it.- Four new tests in
bun-pack.test.tsand one inbun-publish.test.ts, symlink cases skipped on Windows.
Security risks
This is a hardening change in security-adjacent territory (deciding which on-disk files end up in a published tarball / registry payload). The change is strictly more restrictive than before, so the risk profile is "might skip a legitimate bin" rather than "might leak more". I traced the loop for edge inputs (empty after trailing-slash strip → lstat("") fails → Unknown; leading slash → lstat("") fails → Unknown; double slash → extra harmless lstat; . → returns Directory). Bin paths reach this helper already normalized to Posix by resolve_path::normalize_buf::<Posix> in get_package_bins, and absolute/.. paths are already filtered by bin_path_escapes_root.
Level of scrutiny
Moderate-to-high. The diff is small (~35 native lines) but it's new path-component iteration + symlink logic, which is exactly the kind of code that's easy to get subtly wrong and where npm-compat details matter. The PR author verified against npm pack --dry-run on npm 11 and cross-checked cargo check on Windows/macOS targets. The Windows caveat (lstatat reports reparse points as target kind until #38365) is called out and the tests are appropriately skipped there.
Other factors
is_package_bin(the other consumer ofbins) only sets the +x permission bit on files that are already being written; it doesn't gate what the tree walk packs, so skipping a bin from the force-add queue correctly falls through to whatever the tree walk decides.- The DT_UNKNOWN concern for
find_workspace_readme(some filesystems returnd_type = DT_UNKNOWN, which would now be skipped) was raised by a finder and refuted by verifiers — the tarball packing applies the same file-only rule, so the metadata stays consistent with the tarball. - No prior reviews from me or humans on this PR.
|
Same fix, independently arrived at on branch
Unrelated to symlinks but in the same loop, for whoever touches this next: two bin names mapping to the same file pack that file twice, and a |
|
Heads up on the |
…s on Windows lstatat reports a reparse point as its target's kind on Windows, so lstat the joined absolute path instead; the tests now run on Windows too and also cover a --dry-run listing, an absolute-target link, a link inside the package, and an empty bin value.
There was a problem hiding this comment.
I re-checked the two points I raised earlier and both hold up: normalize_buf::<Posix> does route through normalize_string_loose_buf_t → is_sep_any_t (resolve_path.rs:1358→1964), so backslash-spelled bins are normalized to / before the per-component lstat, and 5a0921e now covers that; and keeping != File in find_workspace_readme is consistent with the tarball walk pending #38716. No new issues found this run.
Since this is symlink/path-containment logic in the packer driven by user-controlled bin / directories.bin values and exercised on both POSIX and Windows reparse points, a human sign-off would still be worthwhile.
What was reviewed
bin_kind_without_following_symlinks: trailing-slash stripping (handles both separators), theend-advance loop terminates for empty/.inputs, andjoin_z_spill::<Auto>+lstatper prefix on Windows.get_package_binsnormalization guarantees the/-only split contract.- The
!= Filereadme guard's interaction withEntryKind::Unknown(deliberate, matches the tarball filter; #38716 resolves both).
Extended reasoning...
Overview
Two files changed in src/runtime/cli/: pack_command.rs gains bin_kind_without_following_symlinks() (per-component lstat of a bin path, joined absolute so Windows reparse points are seen) and a guard in the for bin in &bins loop that skips any bin whose final kind isn't File (or Directory for directories.bin). publish_command.rs tightens find_workspace_readme from == Directory to != File so a symlinked README isn't read through and sent as registry metadata. Four new pack tests and one new publish test cover symlinked bins (relative/absolute/inside-package targets), a bin behind a symlinked directory (including the backslash spelling), a symlinked/junction directories.bin, bins that name a directory or are empty, the --dry-run path, and a symlinked README.
Security risks
The change is a security tightening: it closes a hole where bun pm pack / bun publish would follow a symlinked bin (or a bin under a symlinked directory, or a symlinked directories.bin) and pack the target's bytes into the tarball — potentially content outside the package. The fix matches npm-packlist's onstat filtering. Because it is path-containment logic driven by attacker-controllable package.json fields and must behave identically on Windows (junctions, directory symlinks, lstatat vs lstat), a mistake here could either leave the hole open on one platform or over-exclude legitimate bins. I verified the two specific concerns I'd raised earlier: the \-separator case is handled because normalize_buf::<Posix> dispatches to normalize_string_loose_buf_t which splits on is_sep_any_t and emits / (resolve_path.rs:1358–1359, 1963–1964), and the EntryKind::Unknown change is deliberate and internally consistent with the tarball walk's existing != File && != Directory filter.
Level of scrutiny
High. This is exactly the class of code REVIEW.md calls out under Security ("lexical containment is defeated by symlinks", per-component checks, Windows reparse-point semantics). The implementation is small and looks correct — without_trailing_slash strips both separators, the loop handles the "." / empty-after-normalize case (returns Directory, which mismatches File and skips), and lstat on the absolute join is the documented Windows-safe path — but symlink-containment fixes in a packer warrant a human reviewer confirming the Windows behavior and the npm-parity claim.
Other factors
Test coverage is thorough and follows the harness conventions (per-variant, includes the negative assertion that outside content never appears in any entry, exercises --dry-run, uses a junction on Windows). The author verified the tests fail on the current release and pass with the change on both Linux and a Windows x64 debug build, and cross-checked against npm 11's --dry-run output. The two comment-cop nags were addressed (comments trimmed to one line). The one intentional behavior nuance — Unknown d_type on NFS/FUSE now skips the README metadata too — is acknowledged in the PR description and the timeline as coordinated with #38716.
Problem
bun pm pack/bun publishnever pack symlinks (the tree walk only takes regular files and directories, same as npm-packlist), butbin/directories.binentries bypass the walk:pack()adds each bin path to the pack queue directly (src/runtime/cli/pack_command.rs, thefor bin in &binsloop) and lateropenat()s it, which follows symlinks."bin": "sub/cli.js"withsub -> ../elsewhere), or a symlinkeddirectories.binis packed with the contents of the link target, which can be anything outside the package. npm 11 (npm-packlist 10) omits all three; itsonstatdrops every lstat result that is not a file or directory, bins included.binentry that names a directory (or is"", which normalizes to.) failed the whole pack withEISDIR: failed to read file: "lib"and left a truncated.tgzbehind. npm ignores it.bun publishhas the same hole for the readme:find_workspace_readme(src/runtime/cli/publish_command.rs) only skipped directory entries, so aREADME.mdsymlink was read through and its target sent to the registry as thereadme/readmeFilenamemetadata, while the tarball itself (correctly) omitted the README.Fix
bin_kind_without_following_symlinks()lstats every component of a bin path; a bin is only force-added when it is a regular file (or, fordirectories.bin, a real directory) reached through real directories. Anything else is skipped and the normal tree walk decides what gets packed, which gives the npm behavior for the symlink cases and for a bin that names a directory.join(abs_workspace_path, prefix)+bun_sys::lstat) rather thanlstatat(root_dir, ..): on Windowslstatatreports a reparse point as its target's kind (the same thinginstall/prune.rsworks around), whilelstatreports symlinks and junctions as links, so the check works on every platform. This part is folded in from the independent branch linked in the comments below.directories.binvalues commonly carry is stripped before the lstat, sincelstat("bins/")would follow a symlinked directory.find_workspace_readmenow requires a regular file entry, the same test the tarball applies, so the published readme is always the README that is actually in the tarball. Publishing from an existing tarball already did this. (pack, publish: include entries on filesystems whose readdir reports DT_UNKNOWN #38716 additionally resolvesUnknownentry kinds in all of these loops; whichever lands second adapts this one line.)test/cli/install/bun-pack.test.ts(binsblock): symlinked bin (relative and absolute target, and a link inside the package), bin behind a symlinked directory, the same through--dry-run, a junction/symlink asdirectories.bin(spelled./bins/), and bins naming a directory or"". The four new tests fail on the current release and pass with this change; the whole file passes (80 tests).test/cli/install/bun-publish.test.ts(readmeblock): symlinked README is not sent as readme. Fails before, passes after; whole file passes (40 tests).npm pack --dry-run(npm 11.16.0) produce the file lists the tests expect.cargo check -p bun_runtimeforx86_64-pc-windows-msvcandaarch64-apple-darwin.Background
bin(string or object of name -> path) anddirectories.bin(a directory whose files all become bins) in package.json name executables that must end up in the tarball even whenfilesor.npmignorewould exclude them. That is why pack handles them separately from the tree walk: bin files are pushed onto the pack queue before the walk, and the walk skips them to avoid duplicates; adirectories.bindirectory is walked on its own. Thefiles: ["index.js"]in the test fixture is there so that only this bin handling can add the bins.lstatreturns the kind of the path itself rather than of what a symlink points at; checking each prefix of the path is what makes a symlink in the middle of the path (sub -> ../elsewhere) visible, since anlstatof the full path would only look at the last component. Bin paths are normalized to/separators byget_package_binson every platform, so splitting on/is enough.readme/readmeFilenameare fieldsnpm publishputs in the version metadata it sends to the registry; registries render them as the package page. Bun fills them from the first README it finds in the package directory when publishing from a directory, or from the README inside the archive when publishing an existing tarball.Repro on the current release (1.4.0-canary)
directories.binpointing at a symlink to../outside/binspacksbins/a.jsthe same way. For the publish side, aREADME.md -> ../outside/README.mdlink produces a PUT whoseversions[v].readmeis the target's contents although the tarball has no README entry.Earlier revision of this PR: the bin check used
lstatatrelative to the package root, which is correct on POSIX but not on Windows untillstatatthere reports reparse points (#38365), so the symlink tests were skipped on Windows. Superseded by the absolute-path lstat above.no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-publish.test.ts