diff --git a/src/install/PackageManager/PackageManagerDirectories.zig b/src/install/PackageManager/PackageManagerDirectories.zig index d5b1238332..c2146974cb 100644 --- a/src/install/PackageManager/PackageManagerDirectories.zig +++ b/src/install/PackageManager/PackageManagerDirectories.zig @@ -764,6 +764,7 @@ const Output = bun.Output; const Path = bun.path; const Progress = bun.Progress; const default_allocator = bun.default_allocator; +const strings = bun.strings; const Command = bun.cli.Command; const File = bun.sys.File; diff --git a/src/install/extract_tarball.zig b/src/install/extract_tarball.zig index ca6b8f0204..6a0254023d 100644 --- a/src/install/extract_tarball.zig +++ b/src/install/extract_tarball.zig @@ -121,6 +121,12 @@ fn extract(this: *const ExtractTarball, log: *logger.Log, tgz_bytes: []const u8) const basename = brk: { var tmp = name; + // Strip query parameters from the name (e.g., "?token=abc" from "package.tgz?token=abc") + // This is essential on Windows where '?' is an invalid path character + if (strings.indexOfChar(tmp, '?')) |query_index| { + tmp = tmp[0..query_index]; + } + // Handle URLs - extract just the filename from the URL if (strings.hasPrefixComptime(tmp, "https://") or strings.hasPrefixComptime(tmp, "http://")) { tmp = std.fs.path.basename(tmp); @@ -130,15 +136,20 @@ fn extract(this: *const ExtractTarball, log: *logger.Log, tgz_bytes: []const u8) } else if (strings.endsWithComptime(tmp, ".tar.gz")) { tmp = tmp[0 .. tmp.len - 7]; } - } else if (tmp[0] == '@') { + } else if (tmp.len > 0 and tmp[0] == '@') { if (strings.indexOfChar(tmp, '/')) |i| { - tmp = tmp[i + 1 ..]; + if (tmp.len > i + 1) { + tmp = tmp[i + 1 ..]; + } } } if (comptime Environment.isWindows) { + // On Windows, colons are invalid in paths (except for drive letters) + // URLs like "https://example.com/package.tgz" would have a colon if (strings.lastIndexOfChar(tmp, ':')) |i| { - tmp = tmp[i + 1 ..]; + if (i > "C:".len) + tmp = tmp[i + 1 ..]; } } diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index f5af820fd4..3cd3d42525 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2428,3 +2428,57 @@ it("should install tarball with tarball dependencies", async () => { await access(join(add_dir, "node_modules", "test-parent")); await access(join(add_dir, "node_modules", "test-child")); }); + +it("should install tarball with query parameters", async () => { + // Regression test for issue #20647 + // Previously on Windows, tarball URLs with query parameters would fail with BadPathName errors + + // Use a local server to serve the tarball + using server = Bun.serve({ + port: 0, + fetch(req) { + // Serve the same tarball regardless of query parameters + return new Response(Bun.file(join(__dirname, "baz-0.0.3.tgz"))); + }, + }); + const server_url = server.url.href.replace(/\/+$/, ""); + + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + }), + ); + + // Add a tarball with query parameters (used for auth tokens, cache busting, etc.) + const tarballUrl = `${server_url}/package.tgz?token=abc123×tamp=2024`; + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "add", tarballUrl], + cwd: package_dir, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env, + }); + + const err = await stderr.text(); + expect(err).toContain("Saved lockfile"); + const out = await stdout.text(); + expect(out).toContain("installed baz@"); + expect(await exited).toBe(0); + + // Verify the package was actually installed + expect(await readdirSorted(join(package_dir, "node_modules"))).toContain("baz"); + expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toEqual({ + name: "baz", + version: "0.0.3", + bin: { + "baz-run": "index.js", + }, + }); + + // Verify package.json has the dependency with the full URL including query params + const pkg = await file(join(package_dir, "package.json")).json(); + expect(pkg.dependencies["baz"]).toBe(tarballUrl); +});