diff --git a/src/install/npm.zig b/src/install/npm.zig index 332023949d..eabcfd81de 100644 --- a/src/install/npm.zig +++ b/src/install/npm.zig @@ -1077,7 +1077,7 @@ pub const PackageManifest = struct { }; if (json.asProperty("name")) |name_q| { - const field = name_q.expr.asString(allocator) orelse return null; + const received_name = name_q.expr.asString(allocator) orelse return null; // This is intentionally a case insensitive comparision. If the registry is running on a system // with a case insensitive filesystem, you'll be able to install dependencies with casing that doesn't match. @@ -1091,12 +1091,52 @@ pub const PackageManifest = struct { // } // // https://github.com/oven-sh/bun/issues/5189 - if (!strings.eqlCaseInsensitiveASCII(expected_name, field, true)) { - Output.panic("internal: package name mismatch expected \"{s}\" but received \"{s}\"", .{ expected_name, field }); + const equal = if (expected_name.len == 0 or expected_name[0] != '@') + // Unscoped package, just normal case insensitive comparison + strings.eqlCaseInsensitiveASCII(expected_name, received_name, true) + else brk: { + // Scoped package. The registry might url encode the package name changing either or both `@` and `/` into `%40` and `%2F`. + // e.g. "name": "@std%2fsemver" // real world example from crash report + + // Expected name `@` exists, check received has either `@` or `%40` + var received_remain = received_name; + if (received_remain.len > 0 and received_remain[0] == '@') { + received_remain = received_remain[1..]; + } else if (received_remain.len > 2 and strings.eqlComptime(received_remain[0..3], "%40")) { + received_remain = received_remain[3..]; + } else { + break :brk false; + } + + var expected_remain = expected_name[1..]; + + // orelse is invalid because scoped package is missing `/`, but we allow just in case + const slash_index = strings.indexOfChar(expected_remain, '/') orelse break :brk strings.eqlCaseInsensitiveASCII(expected_remain, received_remain, true); + + if (slash_index >= received_remain.len) break :brk false; + + if (!strings.eqlCaseInsensitiveASCIIIgnoreLength(expected_remain[0..slash_index], received_remain[0..slash_index])) break :brk false; + expected_remain = expected_remain[slash_index + 1 ..]; + + // Expected name `/` exists, check that received is either `/`, `%2f`, or `%2F` + received_remain = received_remain[slash_index..]; + if (received_remain.len > 0 and received_remain[0] == '/') { + received_remain = received_remain[1..]; + } else if (received_remain.len > 2 and strings.eqlCaseInsensitiveASCIIIgnoreLength(received_remain[0..3], "%2f")) { + received_remain = received_remain[3..]; + } else { + break :brk false; + } + + break :brk strings.eqlCaseInsensitiveASCII(expected_remain, received_remain, true); + }; + + if (!equal) { + Output.panic("internal: Package name mismatch. Expected \"{s}\" but received \"{s}\"", .{ expected_name, received_name }); return null; } - string_builder.count(field); + string_builder.count(expected_name); } if (json.asProperty("modified")) |name_q| { @@ -1290,10 +1330,10 @@ pub const PackageManifest = struct { string_buf = ptr[0..string_builder.cap]; } - if (json.asProperty("name")) |name_q| { - const field = name_q.expr.asString(allocator) orelse return null; - result.pkg.name = string_builder.append(ExternalString, field); - } + // Using `expected_name` instead of the name from the manifest. We've already + // checked that they are equal above, but `expected_name` will not have `@` + // or `/` changed to `%40` or `%2f`, ensuring lookups will work later + result.pkg.name = string_builder.append(ExternalString, expected_name); get_versions: { if (json.asProperty("versions")) |versions_q| { diff --git a/test/cli/install/registry/bun-install-registry.test.ts b/test/cli/install/registry/bun-install-registry.test.ts index aaa181ce29..baf97e11d1 100644 --- a/test/cli/install/registry/bun-install-registry.test.ts +++ b/test/cli/install/registry/bun-install-registry.test.ts @@ -2370,6 +2370,33 @@ describe("workspaces", async () => { } }); +test("name from manifest is scoped and url encoded", async () => { + await write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "foo", + dependencies: { + // `name` in the manifest for these packages is manually changed + // to use `%40` and `%2f` + "@url/encoding.2": "1.0.1", + "@url/encoding.3": "1.0.1", + }, + }), + ); + + await runBunInstall(env, packageDir); + + const files = await Promise.all([ + file(join(packageDir, "node_modules", "@url", "encoding.2", "package.json")).json(), + file(join(packageDir, "node_modules", "@url", "encoding.3", "package.json")).json(), + ]); + + expect(files).toEqual([ + { name: "@url/encoding.2", version: "1.0.1" }, + { name: "@url/encoding.3", version: "1.0.1" }, + ]); +}); + describe("update", () => { test("duplicate peer dependency (one package is invalid_package_id)", async () => { await write( diff --git a/test/cli/install/registry/packages/@url/encoding.2/encoding.2-1.0.1.tgz b/test/cli/install/registry/packages/@url/encoding.2/encoding.2-1.0.1.tgz new file mode 100644 index 0000000000..eda3a4b608 Binary files /dev/null and b/test/cli/install/registry/packages/@url/encoding.2/encoding.2-1.0.1.tgz differ diff --git a/test/cli/install/registry/packages/@url/encoding.2/package.json b/test/cli/install/registry/packages/@url/encoding.2/package.json new file mode 100644 index 0000000000..fd304e4f12 --- /dev/null +++ b/test/cli/install/registry/packages/@url/encoding.2/package.json @@ -0,0 +1,38 @@ +{ + "name": "@url%2fencoding.2", + "versions": { + "1.0.1": { + "name": "@url/encoding.2", + "version": "1.0.1", + "_id": "@url/encoding.2@1.0.1", + "_nodeVersion": "22.2.0", + "_npmVersion": "10.8.1", + "dist": { + "integrity": "sha512-IWtV06UQpxWKEbRgmgnInjdPSVqaj88gLcbsJKbX4TuvmU9PpArzyHK5h5H73q5CzKoBDIwptb+cKvr98j+QNA==", + "shasum": "bc2994336b291322c242f3570cc486cf8fcc9756", + "tarball": "http://localhost:4873/@url/encoding.2/-/@url/encoding.2-1.0.1.tgz" + }, + "contributors": [] + } + }, + "time": { + "modified": "2024-06-03T00:01:11.853Z", + "created": "2024-06-03T00:01:11.853Z", + "1.0.1": "2024-06-03T00:01:11.853Z" + }, + "users": {}, + "dist-tags": { + "latest": "1.0.1" + }, + "_uplinks": {}, + "_distfiles": {}, + "_attachments": { + "encoding.2-1.0.1.tgz": { + "shasum": "bc2994336b291322c242f3570cc486cf8fcc9756", + "version": "1.0.1" + } + }, + "_rev": "", + "_id": "@url/encoding.2", + "readme": "ERROR: No README data found!" +} diff --git a/test/cli/install/registry/packages/@url/encoding.3/encoding.3-1.0.1.tgz b/test/cli/install/registry/packages/@url/encoding.3/encoding.3-1.0.1.tgz new file mode 100644 index 0000000000..5ecf820cd9 Binary files /dev/null and b/test/cli/install/registry/packages/@url/encoding.3/encoding.3-1.0.1.tgz differ diff --git a/test/cli/install/registry/packages/@url/encoding.3/package.json b/test/cli/install/registry/packages/@url/encoding.3/package.json new file mode 100644 index 0000000000..29c1b03129 --- /dev/null +++ b/test/cli/install/registry/packages/@url/encoding.3/package.json @@ -0,0 +1,38 @@ +{ + "name": "%40url%2fencoding.3", + "versions": { + "1.0.1": { + "name": "@url/encoding.3", + "version": "1.0.1", + "_id": "@url/encoding.3@1.0.1", + "_nodeVersion": "22.2.0", + "_npmVersion": "10.8.1", + "dist": { + "integrity": "sha512-LkuYnUyQgbhee/Sz/QL+WSMzvRElhJqzdYCs6oZFcAlZwEMcmyE10X0LfN6UcQ8zX7z0vjSezs+WinFafTlDSw==", + "shasum": "34a69650f7a471f29578381144110db7319c6992", + "tarball": "http://localhost:4873/@url/encoding.3/-/@url/encoding.3-1.0.1.tgz" + }, + "contributors": [] + } + }, + "time": { + "modified": "2024-06-03T00:01:16.079Z", + "created": "2024-06-03T00:01:16.079Z", + "1.0.1": "2024-06-03T00:01:16.079Z" + }, + "users": {}, + "dist-tags": { + "latest": "1.0.1" + }, + "_uplinks": {}, + "_distfiles": {}, + "_attachments": { + "encoding.3-1.0.1.tgz": { + "shasum": "34a69650f7a471f29578381144110db7319c6992", + "version": "1.0.1" + } + }, + "_rev": "", + "_id": "@url/encoding.3", + "readme": "ERROR: No README data found!" +}