install: ignore leading whitespace in dist-tag, bare folder and local tarball versions
Closed
RO
1mo ago+110+110+012345678901234567890123456789-6-6-0123456789
Problem
- A package.json version with leading whitespace fails to install when it is a dist-tag, a bare folder path or a bare local tarball path, while the same value without the whitespace (and whitespace in front of
^1.0.0,npm:,file:values) installs fine:"bar": " latest":error: Package "bar" with tag " latest" not found, but package exists/error: bar@ latest failed to resolve"pkg": " ./pkg":error: Could not find package.json for "file: ./pkg" dependency "pkg"/error: pkg@ ./pkg failed to resolve"baz": " ./baz-0.0.3.tgz":error: ENOENT extracting tarball from baz/error: baz@ ./baz-0.0.3.tgz failed to resolve
- Cause, in
parse_with_tag(src/install/dependency.rs):parsestrips leading\t\n\rbeforeTag::inferclassifies the literal and passes the stripped bytes on asdependency, but theTag::DistTagarm, the bare-path fallback ofTag::Folder, the bare-path fallback ofTag::Tarballand the fallback ofTag::Symlinkbuild their value fromsliced.value(), the untrimmed literal. The other arms build it fromdependency. So the value is classified as the dist-taglatestbut looked up as" latest". - Second cause, same function:
Version::to_version(every dependency loaded from a bun.lockb) andDependency::clone_with_different_buffers(every lockfile save) callparse_with_tagdirectly with the stored literal, which is not trimmed. The arms slice prefixes offdependency(npm:,file:,git+,catalog:, ...), so a literal with leading whitespace takes the wrong branch on the re-parse. Visible with a binary lockfile:"baz": " file:./baz-0.0.3.tgz"installs, and the nextbun installfails witherror: ENOENT extracting tarball from bazbecause the re-parsed value is" file:./baz-0.0.3.tgz"andVersion::eqlno longer matches package.json.
Fix
- The four arms above build their value from
sliced.sub(dependency), like the remote-tarball andfile:branches next to them already do.literalstayssliced.value(), so bun.lock and package.json keep the text as written (the new test checksbun.lockstill says" latest") and existing lockfiles do not churn. parse_with_tagtrimsdependencyon entry (trim_literal, also used byparseandparse_with_optional_taginstead of their two inline copies), so the two stored-literal callers get the same value the first parse produced. The trim is the identity for every caller that already passed trimmed bytes; the only inputs it changes are stored literals, whose whitespace was already ignored when they were first parsed.- Why this is correct: the value must describe the same bytes
Tag::inferclassified, otherwise the tag and the value disagree. For dist-tags this is also what npm does (npm-package-argtrims registry specs:" latest"resolves the taglatest); for paths npm keeps the whitespace and fails, but bun already accepts" file:./x"and" ./x"is classified as a folder, so the value has to follow the classification. Tag::Symlinkcan only be inferred from alink:literal, so its fallback is unreachable with leading whitespace; it is changed for consistency only and has no test.- Relation to #38309 (open): that PR trims at the sites that classify a literal on their own (package.json editor, string buffer pre-count,
workspace:check, CLI rewrites) and, in its current form, at the two stored-literal callers (clone_with_different_buffers,to_version); its description leaves these arms to a separate fix, which is this PR. The arm changes are needed regardless of it:" latest"," ./pkg"and" ./x.tgz"fail with that branch too. The overlap is the entry trim here vs. its two caller-site trims, which cover the same two paths (both are needed here so that this PR stands on its own; either becomes redundant once the other lands and can be dropped by whichever rebases second). Thetrim_literalhelper and theparse/parse_with_optional_taghunks are byte-identical in both PRs, andgit merge-treeof this branch with its current head (a7d2c3803e) merges without conflicts, so the order they land in does not matter. - Tests:
test/cli/install/bun-install.test.ts,leading whitespace in the version literal(6 tests). Each installs once from package.json and once more with--frozen-lockfilefrom the lockfile the first run wrote, and checks what landed in node_modules:" latest"and"\tlatest"dist-tags," latest"with a text lockfile (also checks the literal in bun.lock), the folder" ./pkg", the tarballs" ./baz-0.0.3.tgz"and" file:./baz-0.0.3.tgz". All 6 fail on the released binary (the errors above; thefile:tarball fails on the second install). With only the arm changes applied, the two tarball tests still fail on the second install, so they are what covers the entry trim. - Also run with the debug build:
bun-install.test.ts(the 14 remaining failures are the bitbucket/gitlab/public-URL tests and fail the same way with the released binary here, no network),bun-lockb,bun-lock,bun-add,bun-update,overrides,catalogs,bun-workspaces,migration/*(complex-workspaceneeds network and fails identically with the released binary) andbun-link(itsshould link dependency without crashingcompares exact stdout and trips over the install-failure stack trace debug builds print; the link: path itself is unchanged,link:literals always take the branch above the fallback).cargo fmtandcargo clippy -p bun_installare clean.
Background
- Version literal: the string value of a dependency entry in package.json (
"^1.0.0","latest","./pkg","file:./x.tgz").Tag::inferclassifies it by its first byte and prefix;parse_with_tagturns it into aDependency.version, which stores the text verbatim asliteral(what bun.lock prints and whatVersion::eqlcompares for dist-tags and folders) plus a tag-specific value (the tag name, the folder path, the tarball path) that resolution uses. SlicedString: a pair of (string buffer, slice into it).sliced.value()makes aStringhandle for the whole slice,sliced.sub(bytes).value()for a sub-range of it; both point into the same buffer, so taking a sub-range costs nothing and is how the other arms extract the part of the literal they need.- Stored-literal re-parse: the lockfile keeps only the tag and the literal for each dependency. Loading a bun.lockb (
to_version) and saving any lockfile (clone_with_different_buffers, which copies each dependency into a fresh string buffer) rebuild the value by callingparse_with_tagagain with the stored literal and tag; bun.lock (text) goes throughparseinstead, which is why the second symptom only shows up with a binary lockfile.