Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions src/runtime/webcore/S3Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ pub const S3Client = struct {
}
return globalThis.throwInvalidArguments("Expected a path", .{});
};
errdefer path.deinit();
const options = args.nextEat();
var blob = Blob.new(try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer));
return blob.toJS(globalThis);
Expand All @@ -151,8 +150,6 @@ pub const S3Client = struct {
}
return globalThis.throwInvalidArguments("Expected a path to presign", .{});
};
errdefer path.deinit();

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
defer blob.detach();
Expand All @@ -169,7 +166,6 @@ pub const S3Client = struct {
}
return globalThis.throwInvalidArguments("Expected a path to check if it exists", .{});
};
errdefer path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
defer blob.detach();
Expand All @@ -186,7 +182,6 @@ pub const S3Client = struct {
}
return globalThis.throwInvalidArguments("Expected a path to check the size of", .{});
};
errdefer path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
defer blob.detach();
Expand All @@ -203,7 +198,6 @@ pub const S3Client = struct {
}
return globalThis.throwInvalidArguments("Expected a path to check the stat of", .{});
};
errdefer path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
defer blob.detach();
Expand All @@ -217,8 +211,8 @@ pub const S3Client = struct {
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to write to", .{}).throw();
};
errdefer path.deinit();
const data = args.nextEat() orelse {
path.deinit();
return globalThis.ERR(.MISSING_ARGS, "Expected a Blob-y thing to write", .{}).throw();
};

Expand Down Expand Up @@ -251,7 +245,6 @@ pub const S3Client = struct {
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to unlink", .{}).throw();
};
errdefer path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this create a memory leak right here if it throws?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — constructS3FileWithS3CredentialsAndOptions now unconditionally takes ownership of path (see the other hunk in S3File.zig). Inside it:

var aws_options = S3.S3Credentials.getCredentialsWithOptions(...) catch |err| {
    path.deinit();
    return err;
};
defer aws_options.deinit();

const store = brk: { ... Blob.Store.initS3(path, ...) ... }; // store owns path from here
errdefer store.deinit();                                      // frees path on any later error

So every error path inside the constructor frees path exactly once, and on success the returned blob owns it (freed by defer blob.detach()).

Between fromJS and the constructor call there is only args.nextEat(), which returns ?jsc.JSValue and cannot throw.

Keeping the old errdefer path.deinit() here is what caused the double free: when s3.unlink(blob.store.?, globalThis, options) (or getPresignUrlFrom in presign) threw after the blob existed, both defer blob.detach() (store → path) and errdefer path.deinit() fired on the same allocation.

defer blob.detach();
Expand Down
18 changes: 16 additions & 2 deletions src/runtime/webcore/S3File.zig
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub fn presign(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J
return globalThis.throwInvalidArguments("Expected a S3 or path to presign", .{});
}
const options = args.nextEat();
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();
return try getPresignUrlFrom(&blob, globalThis, options);
Expand Down Expand Up @@ -113,6 +114,7 @@ pub fn unlink(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
return globalThis.throwInvalidArguments("Expected a S3 or path to delete", .{});
}
const options = args.nextEat();
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();
return try blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
Expand Down Expand Up @@ -150,6 +152,7 @@ pub fn write(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE
if (path == .fd) {
return globalThis.throwInvalidArguments("Expected a S3 or path to upload", .{});
}
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();

Expand Down Expand Up @@ -189,6 +192,7 @@ pub fn size(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr
if (path == .fd) {
return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{});
}
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();

Expand Down Expand Up @@ -222,6 +226,7 @@ pub fn exists(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
if (path == .fd) {
return globalThis.throwInvalidArguments("Expected a S3 or path to check if it exists", .{});
}
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();

Expand All @@ -243,6 +248,7 @@ fn constructS3FileInternalStore(
return constructS3FileWithS3Credentials(globalObject, path, options, existing_credentials);
}
/// if the credentials have changed, we need to clone it, if not we can just ref/deref it
/// Always takes ownership of `path`; freed on error, stored in the returned blob on success.
pub fn constructS3FileWithS3CredentialsAndOptions(
globalObject: *jsc.JSGlobalObject,
path: jsc.Node.PathLike,
Expand All @@ -253,7 +259,10 @@ pub fn constructS3FileWithS3CredentialsAndOptions(
default_storage_class: ?bun.S3.StorageClass,
default_request_payer: bool,
) bun.JSError!Blob {
var aws_options = try S3.S3Credentials.getCredentialsWithOptions(default_credentials.*, default_options, options, default_acl, default_storage_class, default_request_payer, globalObject);
var aws_options = S3.S3Credentials.getCredentialsWithOptions(default_credentials.*, default_options, options, default_acl, default_storage_class, default_request_payer, globalObject) catch |err| {
path.deinit();
return err;
};
defer aws_options.deinit();

const store = brk: {
Expand Down Expand Up @@ -298,13 +307,17 @@ pub fn constructS3FileWithS3CredentialsAndOptions(
return blob;
}

/// Always takes ownership of `path`; freed on error, stored in the returned blob on success.
pub fn constructS3FileWithS3Credentials(
globalObject: *jsc.JSGlobalObject,
path: jsc.Node.PathLike,
options: ?jsc.JSValue,
existing_credentials: S3.S3Credentials,
) bun.JSError!Blob {
var aws_options = try S3.S3Credentials.getCredentialsWithOptions(existing_credentials, .{}, options, null, null, false, globalObject);
var aws_options = S3.S3Credentials.getCredentialsWithOptions(existing_credentials, .{}, options, null, null, false, globalObject) catch |err| {
path.deinit();
return err;
};
defer aws_options.deinit();
const store = bun.handleOom(Blob.Store.initS3(path, null, aws_options.credentials, bun.default_allocator));
errdefer store.deinit();
Expand Down Expand Up @@ -573,6 +586,7 @@ pub fn stat(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr
if (path == .fd) {
return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{});
}
path_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();

Expand Down
110 changes: 110 additions & 0 deletions test/js/bun/s3/s3-path-double-free.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test";

// A non-ASCII character forces the PathLike to be an allocated encoded_slice.
// When the operation fails after the path has been placed in the blob store,
// it must be freed exactly once; previously both the blob store and the
// caller's errdefer would free it, causing an ASAN use-after-poison.
const nonAsciiPath = "bucket/key-ü.txt";

describe("S3Client error paths do not double-free the path", () => {
test("instance presign() throwing after blob creation", () => {
const client = new Bun.S3Client({ accessKeyId: "x", secretAccessKey: "y", endpoint: "http://example.com" });
expect(() => client.presign(nonAsciiPath, { expiresIn: -1 })).toThrow();
expect(() =>
client.presign(nonAsciiPath, {
get method() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("instance presign() throwing before blob creation", () => {
const client = new Bun.S3Client();
expect(() =>
client.presign(nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("instance file() throwing before blob creation", () => {
const client = new Bun.S3Client();
expect(() =>
client.file(nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("instance write() with non-ASCII path and missing data", () => {
const client = new Bun.S3Client();
expect(() => client.write(nonAsciiPath)).toThrow();
});

test.each(["exists", "size", "stat", "unlink"] as const)("instance %s() throwing before blob creation", method => {
const client = new Bun.S3Client();
expect(() =>
client[method](nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("static presign() throwing after blob creation", () => {
expect(() =>
Bun.S3Client.presign(nonAsciiPath, {
accessKeyId: "x",
secretAccessKey: "y",
endpoint: "http://example.com",
expiresIn: -1,
}),
).toThrow();
expect(() =>
Bun.S3Client.presign(nonAsciiPath, {
accessKeyId: "x",
secretAccessKey: "y",
endpoint: "http://example.com",
get method() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("static presign() throwing before blob creation", () => {
expect(() =>
Bun.S3Client.presign(nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test("static file() throwing before blob creation", () => {
expect(() =>
Bun.S3Client.file(nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});

test.each(["exists", "size", "stat", "unlink"] as const)("static %s() throwing before blob creation", method => {
expect(() =>
Bun.S3Client[method](nonAsciiPath, {
get type() {
throw new Error("boom");
},
}),
).toThrow("boom");
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading