Compare commits

...

16 Commits

Author SHA1 Message Date
Claude Bot
09ce0190b4 fix(publish): prevent use-after-free in tarball URL generation
The tarball URL string was being freed via `defer tarball_url_slice.deinit()`
before it was actually used in the dist properties. This caused a use-after-free
bug that manifested as assertion failures, particularly on Windows in debug builds.

The fix duplicates the string using the allocator so it persists beyond the defer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 00:57:34 +00:00
Jarred Sumner
268d4f3045 Fixup 2025-10-07 17:53:09 -07:00
Jarred Sumner
e84a10b51f Merge branch 'main' into dylan/fix-publish-tarball-url 2025-10-07 17:37:00 -07:00
Claude Bot
7827ce24b6 Use URL.join for tarball URL construction
Replace allocPrint string concatenation with URL.join to properly
handle URL path joining. This ensures the pathname is correctly
joined with the base registry URL.
2025-10-05 04:23:17 +00:00
Dylan Conway
b82516610f update 2025-10-03 23:08:43 -07:00
Claude Bot
dc3f562199 fix(test): use platform-specific snapshots for tarball integrity test
Windows doesn't preserve filesystem attribute metadata the same way as
POSIX systems, resulting in different tarball hashes. Use separate
inline snapshots for Windows and POSIX platforms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 04:55:37 +00:00
autofix-ci[bot]
6e44b547ed [autofix.ci] apply automated fixes 2025-10-04 00:29:58 +00:00
Dylan Conway
30c0b7ada8 Update URL.zig 2025-10-03 17:26:58 -07:00
Dylan Conway
acfd99e56d Update immutable.zig 2025-10-03 17:26:18 -07:00
Dylan Conway
feb353208d move WTF::URL bindings to separate file 2025-10-03 17:24:04 -07:00
Dylan Conway
a75ef0877f use WTF::URL 2025-10-03 17:20:46 -07:00
Dylan Conway
34a03152d2 Merge branch 'main' into dylan/fix-publish-tarball-url 2025-10-03 16:47:42 -07:00
Dylan Conway
991a43fae5 update 2025-10-03 16:46:01 -07:00
Dylan Conway
2381393f6d fixup 2025-10-03 16:45:11 -07:00
Dylan Conway
6fb6f0d2cc add test 2025-10-03 16:39:05 -07:00
Dylan Conway
d14e06e1c0 replace protocol correctly 2025-10-03 16:34:11 -07:00
7 changed files with 296 additions and 165 deletions

View File

@@ -116,7 +116,7 @@ pub fn parse(global: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError
};
defer url_str.deref();
const url = jsc.URL.fromString(url_str) orelse return .js_undefined;
const url = jsc.URL.fromString(url_str) catch return .js_undefined;
defer url.deinit();
const host = url.host();
const port_: u16 = blk: {

View File

@@ -528,22 +528,6 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void BunString__toWTFString(BunString* bunStr
bunString->tag = BunStringTag::WTFStringImpl;
}
extern "C" BunString URL__getFileURLString(BunString* filePath)
{
return Bun::toStringRef(WTF::URL::fileURLWithFileSystemPath(filePath->toWTFString()).stringWithoutFragmentIdentifier());
}
extern "C" size_t URL__originLength(const char* latin1_slice, size_t len)
{
WTF::String string = WTF::StringView(latin1_slice, len, true).toString();
if (!string)
return 0;
WTF::URL url(string);
if (!url.isValid())
return 0;
return url.pathStart();
}
extern "C" JSC::EncodedJSValue BunString__toJSDOMURL(JSC::JSGlobalObject* lexicalGlobalObject, BunString* bunString)
{
auto& globalObject = *jsCast<Zig::GlobalObject*>(lexicalGlobalObject);
@@ -559,136 +543,6 @@ extern "C" JSC::EncodedJSValue BunString__toJSDOMURL(JSC::JSGlobalObject* lexica
RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(jsValue));
}
extern "C" WTF::URL* URL__fromJS(EncodedJSValue encodedValue, JSC::JSGlobalObject* globalObject)
{
auto throwScope = DECLARE_THROW_SCOPE(globalObject->vm());
JSC::JSValue value = JSC::JSValue::decode(encodedValue);
auto str = value.toWTFString(globalObject);
RETURN_IF_EXCEPTION(throwScope, nullptr);
if (str.isEmpty()) {
return nullptr;
}
auto url = WTF::URL(str);
if (!url.isValid() || url.isNull())
return nullptr;
return new WTF::URL(WTFMove(url));
}
extern "C" BunString URL__getHrefFromJS(EncodedJSValue encodedValue, JSC::JSGlobalObject* globalObject)
{
auto throwScope = DECLARE_THROW_SCOPE(globalObject->vm());
JSC::JSValue value = JSC::JSValue::decode(encodedValue);
auto str = value.toWTFString(globalObject);
RETURN_IF_EXCEPTION(throwScope, { BunStringTag::Dead });
if (str.isEmpty()) {
return { BunStringTag::Dead };
}
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" BunString URL__getHref(BunString* input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" BunString URL__pathFromFileURL(BunString* input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.fileSystemPath());
}
extern "C" BunString URL__getHrefJoin(BunString* baseStr, BunString* relativeStr)
{
auto base = baseStr->toWTFString();
auto relative = relativeStr->toWTFString();
auto url = WTF::URL(WTF::URL(base), relative);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" WTF::URL* URL__fromString(BunString* input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid())
return nullptr;
return new WTF::URL(WTFMove(url));
}
extern "C" BunString URL__protocol(WTF::URL* url)
{
return Bun::toStringRef(url->protocol().toStringWithoutCopying());
}
extern "C" void URL__deinit(WTF::URL* url)
{
delete url;
}
extern "C" BunString URL__href(WTF::URL* url)
{
return Bun::toStringRef(url->string());
}
extern "C" BunString URL__username(WTF::URL* url)
{
return Bun::toStringRef(url->user());
}
extern "C" BunString URL__password(WTF::URL* url)
{
return Bun::toStringRef(url->password());
}
extern "C" BunString URL__search(WTF::URL* url)
{
return Bun::toStringRef(url->query().toStringWithoutCopying());
}
extern "C" BunString URL__host(WTF::URL* url)
{
return Bun::toStringRef(url->host().toStringWithoutCopying());
}
extern "C" BunString URL__hostname(WTF::URL* url)
{
return Bun::toStringRef(url->hostAndPort());
}
extern "C" uint32_t URL__port(WTF::URL* url)
{
auto port = url->port();
if (port.has_value()) {
return port.value();
}
return std::numeric_limits<uint32_t>::max();
}
extern "C" BunString URL__pathname(WTF::URL* url)
{
return Bun::toStringRef(url->path().toStringWithoutCopying());
}
size_t BunString::utf8ByteLength(const WTF::String& str)
{
if (str.isEmpty())

157
src/bun.js/bindings/URL.cpp Normal file
View File

@@ -0,0 +1,157 @@
#include "root.h"
#include "helpers.h"
using namespace JSC;
extern "C" BunString URL__getFileURLString(BunString* _Nonnull filePath)
{
return Bun::toStringRef(WTF::URL::fileURLWithFileSystemPath(filePath->toWTFString()).stringWithoutFragmentIdentifier());
}
extern "C" size_t URL__originLength(const char* latin1_slice, size_t len)
{
WTF::String string = WTF::StringView(latin1_slice, len, true).toString();
if (!string)
return 0;
WTF::URL url(string);
if (!url.isValid())
return 0;
return url.pathStart();
}
extern "C" WTF::URL* URL__fromJS(EncodedJSValue encodedValue, JSC::JSGlobalObject* globalObject)
{
auto throwScope = DECLARE_THROW_SCOPE(globalObject->vm());
JSC::JSValue value = JSC::JSValue::decode(encodedValue);
auto str = value.toWTFString(globalObject);
RETURN_IF_EXCEPTION(throwScope, nullptr);
if (str.isEmpty()) {
return nullptr;
}
auto url = WTF::URL(str);
if (!url.isValid() || url.isNull())
return nullptr;
return new WTF::URL(WTFMove(url));
}
extern "C" BunString URL__getHrefFromJS(EncodedJSValue encodedValue, JSC::JSGlobalObject* globalObject)
{
auto throwScope = DECLARE_THROW_SCOPE(globalObject->vm());
JSC::JSValue value = JSC::JSValue::decode(encodedValue);
auto str = value.toWTFString(globalObject);
RETURN_IF_EXCEPTION(throwScope, { BunStringTag::Dead });
if (str.isEmpty()) {
return { BunStringTag::Dead };
}
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" BunString URL__getHref(BunString* _Nonnull input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" BunString URL__pathFromFileURL(BunString* _Nonnull input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.fileSystemPath());
}
extern "C" BunString URL__getHrefJoin(BunString* _Nonnull baseStr, BunString* _Nonnull relativeStr)
{
auto base = baseStr->transferToWTFString();
auto relative = relativeStr->transferToWTFString();
auto url = WTF::URL(WTF::URL(base), relative);
if (!url.isValid() || url.isEmpty())
return { BunStringTag::Dead };
return Bun::toStringRef(url.string());
}
extern "C" WTF::URL* URL__fromString(BunString* _Nonnull input)
{
auto&& str = input->toWTFString();
auto url = WTF::URL(str);
if (!url.isValid())
return nullptr;
return new WTF::URL(WTFMove(url));
}
extern "C" BunString URL__protocol(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->protocol().toStringWithoutCopying());
}
extern "C" void URL__setProtocol(WTF::URL* url, BunString newProtocol)
{
String newProtocolStr = newProtocol.toWTFString(BunString::ZeroCopy);
url->setProtocol(newProtocolStr);
}
extern "C" void URL__deinit(WTF::URL* _Nonnull url)
{
delete url;
}
extern "C" BunString URL__href(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->string());
}
extern "C" BunString URL__username(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->user());
}
extern "C" BunString URL__password(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->password());
}
extern "C" BunString URL__search(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->query().toStringWithoutCopying());
}
extern "C" BunString URL__host(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->host().toStringWithoutCopying());
}
extern "C" BunString URL__hostname(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->hostAndPort());
}
extern "C" uint32_t URL__port(WTF::URL* _Nonnull url)
{
auto port = url->port();
if (port.has_value()) {
return port.value();
}
return std::numeric_limits<uint32_t>::max();
}
extern "C" BunString URL__pathname(WTF::URL* _Nonnull url)
{
return Bun::toStringRef(url->path().toStringWithoutCopying());
}

View File

@@ -2,6 +2,7 @@ pub const URL = opaque {
extern fn URL__fromJS(JSValue, *jsc.JSGlobalObject) ?*URL;
extern fn URL__fromString(*bun.String) ?*URL;
extern fn URL__protocol(*URL) String;
extern fn URL__setProtocol(*URL, new_protocol: String) void;
extern fn URL__href(*URL) String;
extern fn URL__username(*URL) String;
extern fn URL__password(*URL) String;
@@ -23,11 +24,9 @@ pub const URL = opaque {
return URL__getHref(&input);
}
pub fn join(base: bun.String, relative: bun.String) String {
pub fn join(base: *bun.String, relative: *bun.String) String {
jsc.markBinding(@src());
var base_str = base;
var relative_str = relative;
return URL__getHrefJoin(&base_str, &relative_str);
return URL__getHrefJoin(base, relative);
}
pub fn fileURLFromString(str: bun.String) String {
@@ -58,18 +57,24 @@ pub const URL = opaque {
return result;
}
pub fn fromUTF8(input: []const u8) ?*URL {
pub fn fromUTF8(input: []const u8) error{InvalidUrl}!*URL {
return fromString(String.borrowUTF8(input));
}
pub fn fromString(str: bun.String) ?*URL {
pub fn fromString(str: bun.String) error{InvalidUrl}!*URL {
jsc.markBinding(@src());
var input = str;
return URL__fromString(&input);
return URL__fromString(&input) orelse {
return error.InvalidUrl;
};
}
pub fn protocol(url: *URL) String {
jsc.markBinding(@src());
return URL__protocol(url);
}
pub fn setProtocol(url: *URL, new_protocol: String) void {
jsc.markBinding(@src());
URL__setProtocol(url, new_protocol);
}
pub fn href(url: *URL) String {
jsc.markBinding(@src());
return URL__href(url);

View File

@@ -970,6 +970,37 @@ pub const PublishCommand = struct {
logger.Loc.Empty,
),
};
var registry_url: *jsc.URL = jsc.URL.fromUTF8(registry.url.href) catch |err| {
Output.err(err, "failed to parse registry url: {s}", .{registry.url.href});
Global.exit(1);
};
defer registry_url.deinit();
// always replace https with http
// https://github.com/npm/cli/blob/9281ebf8e428d40450ad75ba61bc6f040b3bf896/workspaces/libnpmpublish/lib/publish.js#L120
if (registry_url.protocol().eqlUTF8("https")) {
registry_url.setProtocol(bun.String.static("http"));
}
var registry_url_str = registry_url.href();
defer registry_url_str.deref();
var tarball_path_str = bun.String.createFormat("{s}/-/{}", .{
package_name,
Pack.fmtTarballFilename(package_name, package_version, .raw),
}) catch bun.outOfMemory();
defer tarball_path_str.deref();
const tarball_url = jsc.URL.join(&registry_url_str, &tarball_path_str);
defer tarball_url.deref();
const tarball_url_slice = tarball_url.toSlice(bun.default_allocator);
defer tarball_url_slice.deinit();
// Duplicate the tarball URL string so it persists beyond the defer
const tarball_url_str_duped = try allocator.dupe(u8, tarball_url_slice.slice());
dist_props[2] = .{
.key = Expr.init(
E.String,
@@ -979,13 +1010,7 @@ pub const PublishCommand = struct {
.value = Expr.init(
E.String,
.{
.data = try std.fmt.allocPrint(allocator, "http://{s}/{s}/-/{}", .{
// always use replace https with http
// https://github.com/npm/cli/blob/9281ebf8e428d40450ad75ba61bc6f040b3bf896/workspaces/libnpmpublish/lib/publish.js#L120
strings.withoutTrailingSlash(strings.withoutPrefixComptime(registry.url.href, "https://")),
package_name,
Pack.fmtTarballFilename(package_name, package_version, .raw),
}),
.data = tarball_url_str_duped,
},
logger.Loc.Empty,
),
@@ -1454,6 +1479,7 @@ const MutableString = bun.MutableString;
const OOM = bun.OOM;
const Output = bun.Output;
const URL = bun.URL;
const jsc = bun.jsc;
const logger = bun.logger;
const path = bun.path;
const sha = bun.sha;

View File

@@ -100,10 +100,16 @@ pub fn forManifest(
encoded_name = try std.mem.replaceOwned(u8, stack_fallback_allocator.get(), name, "/", "%2f");
}
const tmp = bun.jsc.URL.join(
bun.String.borrowUTF8(scope.url.href),
bun.String.borrowUTF8(encoded_name),
);
const tmp = brk: {
var tmp_base = bun.String.borrowUTF8(scope.url.href);
defer tmp_base.deref();
var tmp_relative = bun.String.borrowUTF8(encoded_name);
defer tmp_relative.deref();
break :brk bun.jsc.URL.join(
&tmp_base,
&tmp_relative,
);
};
defer tmp.deref();
if (tmp.tag == .Dead) {

View File

@@ -849,6 +849,89 @@ it("$npm_lifecycle_event is accurate during publish", async () => {
expect(exitCode).toBe(0);
});
test("tarball is created properly", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await rm(join(registry.packagesPath, "publish-pkg-tarball-test"), { recursive: true, force: true });
await write(join(packageDir, "bunfig.toml"), await registry.authBunfig("check-tarball"));
await write(
packageJson,
JSON.stringify({
name: "publish-pkg-tarball-test",
version: "1.2.3",
}),
);
let { out, exitCode } = await publish(env, packageDir);
expect(exitCode).toBe(0);
expect(out).toContain("+ publish-pkg-tarball-test");
const versions = (await file(join(registry.packagesPath, "publish-pkg-tarball-test/package.json")).json()).versions;
if (isWindows) {
expect(replaceRegistryUrls(versions)).toMatchInlineSnapshot(`
{
"1.2.3": {
"_id": "publish-pkg-tarball-test@1.2.3",
"_integrity": "sha512-1xmj9K6gINm4TigFCVI0XeYv4Kj1699ZrPLdL/upLR4LsBLkgNue6HsoIPdS//Xsw20a+/eL34l64eX3RpgBVQ==",
"_nodeVersion": "24.3.0",
"_npmVersion": "10.8.3",
"contributors": [],
"dist": {
"integrity": "sha512-1xmj9K6gINm4TigFCVI0XeYv4Kj1699ZrPLdL/upLR4LsBLkgNue6HsoIPdS//Xsw20a+/eL34l64eX3RpgBVQ==",
"shasum": "2e3289fea5ac7f2af99ad1d80187ffeecf7caed6",
"tarball": "http://localhost:1234/publish-pkg-tarball-test/-/publish-pkg-tarball-test-1.2.3.tgz",
},
"integrity": "sha512-1xmj9K6gINm4TigFCVI0XeYv4Kj1699ZrPLdL/upLR4LsBLkgNue6HsoIPdS//Xsw20a+/eL34l64eX3RpgBVQ==",
"name": "publish-pkg-tarball-test",
"shasum": "2e3289fea5ac7f2af99ad1d80187ffeecf7caed6",
"version": "1.2.3",
},
}
`);
} else {
expect(replaceRegistryUrls(versions)).toMatchInlineSnapshot(`
{
"1.2.3": {
"_id": "publish-pkg-tarball-test@1.2.3",
"_integrity": "sha512-4TZniDJ86iVpuDHLjaefCE3LjgbymU9XYUhQPCeCv8MDRDB5cTQPRovhGIh2PGfrx9RQunpBPR+qblKBsn4NFQ==",
"_nodeVersion": "24.3.0",
"_npmVersion": "10.8.3",
"contributors": [],
"dist": {
"integrity": "sha512-4TZniDJ86iVpuDHLjaefCE3LjgbymU9XYUhQPCeCv8MDRDB5cTQPRovhGIh2PGfrx9RQunpBPR+qblKBsn4NFQ==",
"shasum": "5b15f85ecd0e83c37131654879a9592ea2a2a660",
"tarball": "http://localhost:1234/publish-pkg-tarball-test/-/publish-pkg-tarball-test-1.2.3.tgz",
},
"integrity": "sha512-4TZniDJ86iVpuDHLjaefCE3LjgbymU9XYUhQPCeCv8MDRDB5cTQPRovhGIh2PGfrx9RQunpBPR+qblKBsn4NFQ==",
"name": "publish-pkg-tarball-test",
"shasum": "5b15f85ecd0e83c37131654879a9592ea2a2a660",
"version": "1.2.3",
},
}
`);
}
await rm(join(registry.packagesPath, "publish-pkg-tarball-test"), { recursive: true, force: true });
});
function replaceRegistryUrls(obj: any): any {
if (typeof obj === "string") {
return obj.replaceAll(/localhost:\d+/g, "localhost:1234");
}
if (Array.isArray(obj)) {
return obj.map(item => replaceRegistryUrls(item));
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const key in obj) {
result[key] = replaceRegistryUrls(obj[key]);
}
return result;
}
return obj;
}
describe("--tolerate-republish", async () => {
test("republishing normally fails", async () => {
const { packageDir, packageJson } = await registry.createTestDir();