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
18 changes: 12 additions & 6 deletions src/runtime/webcore/S3Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to presign", .{}).throw();
}
Expand All @@ -155,6 +155,7 @@ pub const S3Client = struct {

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.getPresignUrlFrom(&blob, globalThis, options);
}
Expand All @@ -163,7 +164,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check if it exists", .{}).throw();
}
Expand All @@ -172,6 +173,7 @@ pub const S3Client = struct {
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);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.exists(globalThis, &blob);
}
Expand All @@ -180,7 +182,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check the size of", .{}).throw();
}
Expand All @@ -189,6 +191,7 @@ pub const S3Client = struct {
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);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.size(globalThis, &blob);
}
Expand All @@ -197,7 +200,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check the stat of", .{}).throw();
}
Expand All @@ -206,6 +209,7 @@ pub const S3Client = struct {
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);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.stat(globalThis, &blob);
}
Expand All @@ -214,7 +218,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(3).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var 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();
Expand All @@ -224,6 +228,7 @@ pub const S3Client = struct {

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
var blob_internal: PathOrBlob = .{ .blob = blob };
return Blob.writeFileInternal(globalThis, &blob_internal, data, .{
Expand All @@ -248,12 +253,13 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var 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);
path = .{ .string = bun.PathString.empty };
defer blob.detach();
return blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
}
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/webcore/S3File.zig
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ pub fn presign(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J
}
const options = args.nextEat();
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
// Path ownership transferred to blob's store via toThreadSafe().
// Prevent errdefer from double-freeing the underlying string.
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };
defer blob.deinit();
return try getPresignUrlFrom(&blob, globalThis, options);
},
Comment on lines 84 to 92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 This PR correctly fixes the double-free when constructS3FileInternalStore succeeds but the subsequent operation fails. However, a pre-existing edge case remains: if constructS3FileInternalStore fails internally after Store.initS3 has already run (e.g., the "type" option getter throws), the errdefer store.deinit() inside constructS3FileWithS3Credentials and the outer errdefer path_or_blob.path.deinit() in presign/unlink/etc. both fire on the same WTFStringImpl. The sentinel assignment is only reached on successful return from constructS3FileInternalStore, providing no protection for this internal failure path.

Extended reasoning...

Remaining double-free inside constructS3FileInternalStore failures

What the bug is

The PR fixes the case where constructS3FileInternalStore returns successfully but the subsequent S3 operation fails. However, constructS3FileInternalStore calls constructS3FileWithS3Credentials, which can itself fail after Blob.Store.initS3 has already run, leaving the caller holding a stale reference to a WTFStringImpl that the store also owns.

The specific code path

In constructS3FileWithS3Credentials (visible in the modified file context):

  1. getCredentialsWithOptions(...) is called with try -- if this fails, initS3 has not yet run, so no ownership issue.
  2. bun.handleOom(Blob.Store.initS3(path, ...)) runs successfully. Inside initS3 (Store.zig:98-100): var path = pathlike; path.toThreadSafe(); -- a shallow copy of the PathLike is made, then toThreadSafe() is called on the local copy.
  3. errdefer store.deinit() is registered after initS3.
  4. opts.getTruthyComptime(globalObject, "type") is a try call -- if the options object has a getter for "type" that throws a JS exception, this propagates as JSError.
  5. The errdefer store.deinit() fires -> s3.deinit(allocator) -> this.pathlike.deinit() -> decrefs the WTFStringImpl.
  6. The error propagates back to e.g. presign(). The sentinel path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } } is on the line AFTER the try constructS3FileInternalStore(...) call and is never reached.
  7. The outer errdefer path_or_blob.path.deinit() fires -> second deref on the same WTFStringImpl.

Why existing code does not prevent it

For already-thread-safe WTFStrings: String.toThreadSafe() is documented as "Does not increment the reference count unless the StringImpl is cloned" -- BunString__toThreadSafe calls isolatedCopy() which returns the same ptr for already-thread-safe strings. So the store copy and path_or_blob.path share the same WTFStringImpl with no extra refcount. Two deref() calls -> double-free.

For not-yet-thread-safe strings: SliceWithUnderlyingString.toThreadSafe() creates a clone Y for the store and calls orig.deref() on the original ptr X (inside the local copy which is a bitwise copy of the WTFStringImpl also held by path_or_blob.path). This consumes a reference that the caller's path_or_blob.path also held. The outer errdefer then calls deref() on the potentially freed X -> use-after-free. The refutation's argument that "different ptrs means no double-free" overlooks that X's refcount was decremented during initS3, potentially freeing it before the outer errdefer fires.

The PR sentinel is placed correctly for the success path but provides zero protection when constructS3FileInternalStore fails mid-execution.

Step-by-step proof

Trigger: Bun.S3Client.presign("some-key", { get type() { throw new Error("oops") } })

  1. presign() parses "some-key" -> path_or_blob = .{ .path = .slice_with_underlying_string{X} } (refcount=1).
  2. errdefer path_or_blob.path.deinit() registered.
  3. try constructS3FileInternalStore(globalThis, path.path, options) called.
  4. Inside constructS3FileWithS3Credentials: getCredentialsWithOptions succeeds (creates empty credentials).
  5. initS3(path, ...) runs: shallow-copies PathLike, calls path.toThreadSafe(). For already-thread-safe strings: no-op, store and caller share WTFStringImpl X with refcount=1. errdefer store.deinit() registered.
  6. opts.getTruthyComptime(globalObject, "type") executes the getter -> throws -> JSError returned.
  7. errdefer store.deinit() fires -> pathlike.deinit() -> X.deref() -> refcount drops to 0 -> X freed.
  8. JSError propagates to presign(). Line path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } } is never executed.
  9. errdefer path_or_blob.path.deinit() fires -> X.deref() on freed memory -> crash/UB in debug+ASAN builds.

How to fix

The cleanest fix is to have initS3 (or the caller before passing to initS3) increment the refcount so both the store and the caller hold independent references. Alternatively, the sentinel assignment could be placed inside constructS3FileWithS3Credentials itself right after initS3 succeeds. As-written, the PR's approach of nullifying after a successful try constructS3FileInternalStore(...) cannot cover failures that occur inside that function.

Addressing the refutation

The refutation claims regular JS string literals are not already thread-safe, so toThreadSafe() always clones and the paths are independent. This is true for the strict same-ptr double-deref scenario, but misses the use-after-free path for non-thread-safe strings: initS3 decrements the refcount of the original WTFStringImpl via orig.deref() inside toThreadSafe(), and the outer errdefer then fires on the same now-freed object. Both string-safety states lead to memory unsafety on this failure path. The trigger (throwing type getter) is unusual but valid JS, and the bug predates this PR.

Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -114,6 +117,7 @@ pub fn unlink(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
}
const options = args.nextEat();
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };
defer blob.deinit();
return try blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
},
Expand Down Expand Up @@ -151,6 +155,7 @@ pub fn write(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE
return globalThis.throwInvalidArguments("Expected a S3 or path to upload", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };
defer blob.deinit();

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

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

return S3BlobStatTask.exists(globalThis, &blob);
Expand Down Expand Up @@ -574,6 +581,7 @@ pub fn stat(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr
return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };
defer blob.deinit();

return S3BlobStatTask.stat(globalThis, &blob);
Comment on lines 581 to 587

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 The stat() function has two error messages copied from size() that say "Expected a S3 or path to get size" instead of something stat-specific. This is a pre-existing copy-paste issue — the PR touches this function (adds the sentinel at line 584) but does not correct the misleading messages.

Extended reasoning...

Copy-pasted error messages in stat() say "to get size" instead of "to stat"

What the bug is

In S3File.zig, the stat() function contains two throwInvalidArguments calls with the message "Expected a S3 or path to get size". This message was copy-pasted from the size() function and was never updated to reflect the stat operation.

The specific code path

The two affected lines in stat() are:

  1. The blob-validity check: return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{}); — triggered when a non-S3 blob is passed.
  2. The fd check inside the .path branch: return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{}); — triggered when a file descriptor is passed instead of a path or S3 blob.

Both messages are factually incorrect for a stat context.

Why existing code does not prevent it

This is a pure copy-paste oversight. The size() function at lines ~190-196 uses identical logic and the same message. When stat() was added (or copy-pasted from size()), the error strings were never updated. No test validates the exact error message text for these specific failure paths of stat(), so the incorrect messages went unnoticed.

Impact

A user calling Bun.S3Client.stat(someNonS3Blob) or Bun.S3Client.stat(fd) receives the error message "Expected a S3 or path to get size", which is actively misleading — it suggests a size operation failed, not a stat operation. This makes debugging harder when the user is trying to understand why their stat() call is rejected.

Step-by-step proof

  1. Call Bun.S3Client.stat(regularBlob) where regularBlob is a plain (non-S3) Blob.
  2. stat() checks: path_or_blob == .blob and (path_or_blob.blob.store == null or path_or_blob.blob.store.?.data != .s3) — this is true.
  3. throwInvalidArguments("Expected a S3 or path to get size", .{}) is called.
  4. The user sees: TypeError: Expected a S3 or path to get size — confusingly referencing "get size" for a stat call.

How to fix

Replace both instances of "Expected a S3 or path to get size" inside stat() with "Expected a S3 or path to stat" (or similar). The equivalent messages in other methods like exists() correctly use operation-specific phrasing ("to check if it exists", "to delete", etc.).

Pre-existing nature

This bug predates this PR — the wrong messages were present before the double-free fix was applied. The PR modifies stat() by adding the sentinel assignment (path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }) at line 584, touching this function without addressing the copy-paste error. Since the PR author is already editing these lines, this is a good opportunity to fix the messages alongside the double-free fix.

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

// Regression test for double-free of path in S3 static and instance methods.
// The bug crashes under ASAN/debug builds when signRequest fails after
// constructS3FileInternalStore/constructS3FileWithS3CredentialsAndOptions
// transfers path ownership to the blob store via toThreadSafe().
// Both errdefer and defer would deref the same WTFStringImpl.

// Instance method path (S3Client.zig) — uses explicit empty credentials
// to ensure missing-credentials path regardless of ambient AWS/S3 env vars.
test("S3Client instance presign does not crash on missing credentials", () => {
const client = new Bun.S3Client({ accessKeyId: "", secretAccessKey: "" });
expect(() => client.presign("a")).toThrow("Missing S3 credentials");
expect(() => client.presign("some-key", {})).toThrow("Missing S3 credentials");
});
Comment on lines +1 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The test file only contains one subprocess-based crash regression test (for presign); the other 5 patched methods — write, unlink, size, exists, and stat — have no crash regression tests at all. Since the double-free causes a process crash (SIGILL/ASAN) rather than a catchable JS exception, only subprocess-based tests (verifying exit code = 0) can serve as regression tests for this specific crash scenario. If the sentinel assignment is accidentally removed from any of the other 5 call sites in a future refactor, no test will detect the regression.

Extended reasoning...

The final committed test file test/js/bun/s3/s3-presign-path-deinit.test.ts (24 lines) contains exactly ONE test() block, a subprocess-based regression test for presign. The PR patches 6 static methods in S3File.zigpresign, unlink, write, size, exists, and stat — by adding a sentinel assignment (path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }) after constructS3FileInternalStore succeeds. Only presign has a subprocess crash regression test.

The double-free bug this PR fixes occurs at the Zig level: when constructS3FileInternalStore transfers path ownership to the blob store via toThreadSafe(), both defer blob.deinit() and errdefer path_or_blob.path.deinit() fire on the same underlying WTFStringImpl if the subsequent operation fails. This results in a crash in debug+ASAN builds (SIGILL or ASAN abort), not a catchable JS exception. In-process assertions using .toThrow() or .rejects.toThrow() cannot detect this class of regression — only subprocess tests that check exit code = 0 can.

The PR description states "Tests cover all 6 methods" but this does not match the committed code. The timeline shows there were earlier iterations with tests for the other 5 methods using expect(() => ...).toThrow() and later async/.rejects.toThrow() patterns, which were flagged as incorrect and apparently removed or never committed. The final file reflects none of these.

The impact is that if anyone refactors unlink, write, size, exists, or stat in S3File.zig and accidentally removes the path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } } sentinel, the double-free regression will go undetected by CI in release builds (the crash only manifests in debug+ASAN). This is a concrete regression risk since the sentinel is a one-liner that could easily be dropped in a merge conflict or refactor.

A subprocess test following the same pattern as the presign test would provide the needed coverage for each missing method. For example:

test("s3 static write with missing credentials does not crash", async () => {
  await using proc = Bun.spawn({
    cmd: [bunExe(), "-e", `Bun.S3Client.write("a", "data").catch(e => console.log(e.code))`],
    env,
    stdout: "pipe",
    stderr: "pipe",
  });
  const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
  expect(stdout.trim()).toBe("ERR_S3_MISSING_CREDENTIALS");
  expect(exitCode).toBe(0);
});

The same pattern applies to unlink, size, exists, and stat.

No verifiers refuted this finding. The Zig fix itself is correct and complete; this is purely a test coverage gap for 5 of the 6 patched call sites.


// Static method path (S3File.zig) — exercises the separate static entrypoint.
test("S3Client static presign does not crash on missing credentials", () => {
expect(() => Bun.S3Client.presign("a")).toThrow("Missing S3 credentials");
});
Comment on lines +9 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Both tests in this file are non-hermetic on machines with AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (or S3_*) exported. The static test on L19 passes no options at all, and the instance test's { accessKeyId: "", secretAccessKey: "" } does not override env credentials — getCredentialsWithOptions reads these via getTruthyComptime, which treats empty strings as absent — so the comment on L9-10 is incorrect. CI is unaffected (it doesn't set these vars), but for local hermeticity the test should delete process.env.AWS_ACCESS_KEY_ID etc. (or run in a subprocess with a stripped env) rather than relying on empty-string options.

Extended reasoning...

What the bug is

Both tests in s3-presign-path-deinit.test.ts assert .toThrow("Missing S3 credentials"), which only fires when accessKeyId.len == 0 or secretAccessKey.len == 0. Neither test reliably reaches that state on a developer machine with ambient AWS credentials:

  • L18-20 (static): Bun.S3Client.presign("a") passes no options at all. constructS3FileInternalStoreenv.getS3Credentials() (env_loader.zig:80-101) reads S3_ACCESS_KEY_ID/AWS_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY/AWS_SECRET_ACCESS_KEY directly from the process environment.
  • L11-15 (instance): the comment on L9-10 claims { accessKeyId: "", secretAccessKey: "" } "ensure[s] the missing-credentials path regardless of ambient AWS/S3 env vars", but this is false. S3Client.constructor calls getCredentialsWithOptions(env.getS3Credentials(), .{}, opts, …), which initializes new_credentials.credentials from the env-derived defaults and then reads overrides via opts.getTruthyComptime(globalObject, "accessKeyId") (credentials_jsc.zig:21,36). truthyPropertyValue (JSValue.zig:1636-1641) explicitly returns null for empty strings ("Ignore empty string"), and even past that there's a second str.tag != .Empty guard. So { accessKeyId: "", secretAccessKey: "" } is treated identically to {} and the env credentials pass through unchanged.

Why nothing prevents it

test/harness.ts defines bunEnv = { ...process.env, … } and does not strip AWS_*/S3_*; test/preload.ts only deletes keys not in bunEnv, so ambient credentials survive into the test process. There is no other isolation.

This is a follow-on to resolved comment #2981748440, which flagged the env-dependence and suggested the empty-string-credentials approach. That fix was applied to the instance test, but (a) it doesn't actually work because of getTruthyComptime, and (b) the static test added afterward at L19 has no guard at all.

Step-by-step proof

  1. Developer has export AWS_ACCESS_KEY_ID=AKIA… and export AWS_SECRET_ACCESS_KEY=… in their shell.
  2. bun test test/js/bun/s3/s3-presign-path-deinit.test.ts runs; preload does not strip these vars.
  3. Instance test: new Bun.S3Client({ accessKeyId: "", secretAccessKey: "" })getCredentialsWithOptions starts from env credentials → getTruthyComptime("accessKeyId") returns null (empty string filtered) → env accessKeyId/secretAccessKey are kept. client.presign("a")signRequest sees non-empty creds → does not return error.MissingCredentials (it either succeeds or fails with InvalidPath). expect(…).toThrow("Missing S3 credentials") fails.
  4. Static test: Bun.S3Client.presign("a")env.getS3Credentials() reads the same env vars → same outcome, assertion fails.

Impact

CI is unaffected — Bun's test runners use S3_R2_* secret names (test/js/bun/s3/s3.test.ts:21-24) rather than AWS_*/S3_ACCESS_KEY_ID, and the only places that set AWS_* are release-upload scripts that don't run tests. This only bites local developers with AWS tooling configured, hence nit severity. The misleading comment on L9-10 should be corrected regardless.

How to fix

Since empty-string options don't override env credentials, the only reliable approach is to strip the env vars. For example, at the top of the file:

for (const k of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_SESSION_TOKEN", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_REGION", "S3_SESSION_TOKEN", "S3_BUCKET", "AWS_BUCKET", "S3_ENDPOINT", "AWS_ENDPOINT"]) {
  delete process.env[k];
}

(or run the calls in a Bun.spawn subprocess with a clean env, as an earlier iteration of this PR did). Note that transpiler.env caches getS3Credentials() after first call, so the deletes must happen before any S3 API is touched in the process — placing them at module top level (before the test() blocks) is sufficient here.

Loading