install: reject tarball, folder and git packages whose package.json name is invalid - #38633
install: reject tarball, folder and git packages whose package.json name is invalid#38633robobun wants to merge 1 commit into
Conversation
…ame is invalid
Package::parse copied the name out of a non-root package's package.json
verbatim, so a tarball, folder, git or workspace package named e.g. "a:b"
was written to bun.lock as "a:b@<resolution>". The bun.lock parser rejects
such names ("Invalid package name"), so every following install ignored the
lockfile and rewrote it, --frozen-lockfile always failed and bun pm ls
failed with InvalidLockfile.
Apply the lockfile parser's check (dependency::is_safe_install_folder_name)
when the name is read, log an error pointing at the name in that
package.json, and fail the install before anything is saved. The root
package is exempt: its name is not a bun.lock packages entry.
The tarball and git arms of process_extracted_tarball_package exit through
PackageManager::crash so the logged reason is printed before exiting.
|
Warning Review limit reached
Next review available in: 26 seconds 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 (3)
Comment |
|
Status: ready for review. Reproduced on the released binary (1.4.0-canary.1) with a local tarball and with a With this change the install fails with |
There was a problem hiding this comment.
LGTM — a focused validation fix that reuses the lockfile parser's own predicate so bun never writes a lockfile it can't reload.
What was reviewed:
- The new check in
parse_with_json_implgates on!FEATURES.is_main; verified onlyFeatures::MAINsetsis_main: true, so workspace/folder/tarball/git all pass through the check while the root stays exempt. Global::crash()→self.crash()in the git and tarball arms: confirmedPackageManager::crashprints the accumulated log then callsGlobal::crash(), and the remainingGlobal::crash()in the npm arm keeps theGlobalimport live.- Error uses existing machinery (
log.add_error_fmt,value_loc_of,bun_core::fmt::quote,InvalidPackageJSON) matching sibling error paths in the same file. - Tests cover tarball/folder/git rejection, escaping of the echoed name, plus positive controls (scoped names still install and
--frozen-lockfilereloads; invalid root name still accepted); test helperscreateDumbHttpGitRepo/serveDirectoryare reused from the same file.
Extended reasoning...
Overview
Three files touched. src/install/lockfile/Package.rs gains a 10-line guard inside the 'name: block of parse_with_json_impl: for any non-root package.json, the name field is now run through dependency::is_safe_install_folder_name, and on failure an Invalid package name "…" error is logged (pointing at the value's location via value_loc_of) and InvalidPackageJSON is returned. src/install/PackageManager/processDependencyList.rs swaps two Global::crash() calls for self.crash() in the git and tarball arms of process_extracted_tarball_package, so the newly logged error (and any other InvalidPackageJSON reason from a tarball) is actually printed before exit. test/cli/install/bun-install.test.ts adds a six-test describe.concurrent block.
Security risks
None introduced. The change tightens validation of a name string that comes from an untrusted package.json before it is written into the lockfile and later used as a filesystem path. It reuses is_safe_install_folder_name, the same predicate the lockfile loader and isolated linker already enforce (rejects empty, ., .., empty segments, and any \\, : or NUL). The rejected name is echoed via bun_core::fmt::quote, which JSON-escapes control characters, so the error message can't be used for terminal injection. Failing closed here is strictly safer than the previous behavior of writing an unloadable lockfile.
Level of scrutiny
Low-to-medium. This is package-manager code, but the change is a small, guarded early-return that reuses an existing, well-exercised predicate rather than introducing new parsing logic. The is_main gate is the established discriminator for root vs. dependency in this function (it already drives meta.origin a few lines above and several later branches). The Global::crash() → self.crash() swap is a strict superset: PackageManager::crash prints the log (unless --silent) and then calls Global::crash(). I checked that the string_buf shared borrow in each arm is scoped inside the if log_level != Silent block, so self.crash()'s &mut self receiver is unencumbered.
Other factors
Test coverage is thorough for the size of the change: four rejection cases (local tarball, file: folder with location assertion, NUL-byte escaping, git via a local dumb-http repo — no external network) and two positive controls proving scoped names still install and round-trip through --frozen-lockfile, and that an invalid root name remains accepted. The tests reuse existing helpers from the same file and follow the harness conventions (tempDir, bunEnv spread, drain stdout/stderr/exited concurrently, assert output before exit code). The PR description explicitly scopes out the empty-name / @-in-name lockfile encoding issue as a separate concern, which is the right call — that's a serialization format problem, not a name-validity one.
Problem
file:tarball,file:folder, git or workspace dependency whose ownpackage.jsonhas a name such as"a:b"installs fine, andbun installwrites"x": ["a:b@../evil.tgz", ...]into bun.lock.error: Invalid package name,src/install/lockfile/bun.lock.rsline 2357), so every followingbun installprintswarn: Ignoring lockfile, re-resolves and writes the same lockfile again,bun install --frozen-lockfilealways fails withlockfile had changes, but lockfile is frozen, andbun pm lsfails witherror: Error loading lockfile: InvalidLockfile.Package::parse_with_json_impl(src/install/lockfile/Package.rs, the'name:block near line 2064) copies any non-emptynameout of the package's package.json. Nothing applies the check the lockfile parser applies on reload. Registry packages are not affected because their name is the requested, already validated name; this only concerns names read out of tarball, folder, git and workspace packages."a:b" is not a valid install folder name, the name is a store folder there), so only the hoisted linker got as far as saving the lockfile.Fix
Package::parse_with_json_implrunsdependency::is_safe_install_folder_nameon the name of every non-root package. On failure it logsInvalid package name "<name>"pointing at thenamevalue in that package.json and returnsInvalidPackageJSON, the error this function already uses for package.json contents it refuses, so the install fails before a lockfile is saved.bun_core::fmt::quote, which JSON-escapes control characters and quotes.:/\,./..segments, empty segments), so no package produced by normal tooling is affected. A substituted name would also never match the installed package.json, sobun installwould reinstall the package every run.FEATURES.is_main): its name is only written to theworkspacessection, which accepts it, and a root named"a:b"installs and reloads fine today.process_extracted_tarball_package(src/install/PackageManager/processDependencyList.rs) now exit throughPackageManager::crash()instead ofGlobal::crash(), so the logged reason is printed; without that, the new error (like anyInvalidPackageJSONfrom a tarball today) would only show up asexpected package.json in ../evil.tgz to be a JSON file: InvalidPackageJSON. The folder and workspace paths already printed the log. The npm arm of that match is not on this path and is unchanged."x": ["@file:../dir", {}]) and names containing@also produce a lockfile that does not load back, but that is a problem of thename@resolutionencoding rather than of name validation, and is left for a separate change.test/cli/install/bun-install.test.ts, describea dependency whose own package.json has an invalid name: local tarball,file:folder (asserts the error location), escaping of the echoed name, git dependency (local dumb-http repo), plus two controls that must keep passing: a scoped@scope/deptarball still installs and the saved bun.lock loads under--frozen-lockfile, and an invalid root name is still accepted. The four rejection tests fail on the released binary and pass with this change; the controls pass on both.bun-install.test.ts(remaining failures are the bitbucket/gitlab network tests and two tests that fail identically without this change in this environment),bun-workspaces,bun-add,bun-patch,isolated-install,migration/migrate, and the folder/tarball/workspace/git subset ofbun-install-registry; all green.Background
"<path>": ["<name>@<resolution>", ...]. On load, the name is split back out and checked withis_safe_install_folder_name, because package names from the lockfile become folder names: the npm cache folder for registry packages and thenode_modules/.bun/<name>@...store folder for isolated installs. A name failing the check makes the whole lockfile unloadable, which bun reports asIgnoring lockfileand treats as "no lockfile".is_safe_install_folder_name(src/install/dependency.rs) rejects empty names,./..or empty/-separated segments, and segments containing\,:or NUL;@scope/namepasses.Package::parse_with_json_implis the one function that turns a package.json into a lockfilePackagefor everything that is not a registry manifest: the root (Features::MAIN, the only caller withis_main), workspace members,file:folders,link:targets, and the package.json extracted from tarball and git dependencies. That is why the check lives there.PackageManager::crash()prints the accumulated install log to stderr (unless--silent) and exits 1;Global::crash()just exits 1.