Skip to content

redis: reject file-backed Blob arguments instead of sending the empty string - #36836

Open
robobun wants to merge 4 commits into
mainfrom
farm/2235418b/redis-file-blob-empty
Open

robobun wants to merge 4 commits into
mainfrom
farm/2235418b/redis-file-blob-empty

Conversation

@robobun

@robobun robobun commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

RedisClient accepts Blob for any KeyLike parameter (key or value). The argument converter read the Blob's bytes via shared_view(), which is empty for a file- or S3-backed store. So:

await redis.set("tpl", Bun.file("page.html"));

sent SET "tpl" "" on the wire, resolved "OK", and silently dropped the file contents. The same applied to:

  • a Bun.file() as a key (became the empty key "")
  • a single-part new Blob([Bun.file(p)]) (shares the file store)
  • a nonexistent path (no ENOENT, just "")

Wire-verified on stock 1.4.0-canary and release-asan main; deterministic.

Cause

js_valkey_functions.rs::from_js calls BlobOrStringOrBuffer::from_js_maybe_file(global, value, false). The false skips the file-blob guard in from_js_maybe_file_maybe_async, so a file-backed Blob falls through to Self::Blob(blob.dupe()) and slice() returns its empty shared_view(). (The allow_file parameter there is named the opposite of its effect; left alone to keep the diff small.)

Fix

After conversion, check needs_to_read_file() || is_s3() on the Blob arm and throw ERR_INVALID_ARG_TYPE with "pass await blob.bytes() instead", matching ServerWebSocket.send and the MySQL parameter binder. All ~50 prototype methods and .send() route through this one helper, so the check covers every argument position.

Not changed

A Uint8Array over a detached ArrayBuffer still serializes as "". Per spec a detached view has byteLength === 0, and Node treats it as empty in comparable APIs, so sending zero bytes is arguably correct; happy to reject it too if preferred.

How did you verify your code works?

Added two dockerless tests to test/js/valkey/unit/buffer-operations.test.ts that stand up a minimal RESP3 recorder:

  • in-memory / sliced Blobs reach the wire with their bytes intact (regression guard)
  • Bun.file() as value, as key, wrapped, after .text(), nonexistent path, and via getBuffer/append all throw and produce no wire frame
USE_SYSTEM_BUN=1 bun test buffer-operations.test.ts -t "Blob argument"   # 1 pass, 1 fail
bun bd test buffer-operations.test.ts -t "Blob argument"                 # 2 pass

[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/unit/buffer-operations.test.ts
bun test v1.4.0 (23619b79a)

test/js/valkey/unit/buffer-operations.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey: Buffer Operations > getBuffer returns binary data as Uint8Array
(skip) Valkey: Buffer Operations > getBuffer for non-existent key returns null
(skip) Valkey: Buffer Operations > Really long buffer
(skip) Valkey: Buffer Operations > Buffer with no bytes
(skip) Valkey: Buffer Operations > Buffer with null bytes
(skip) Valkey: Buffer Operations > concurrent getBuffer against large blob
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBufferView key
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBuffer key
(skip) Valkey: Buffer Operations > set and getBuffer with Blob key
(pass) Valkey: Blob argument serialization > in-memory Blob arguments reach th
... (truncated)

release without fix: 1 failed, 9 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/valkey/unit/buffer-operations.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey: Buffer Operations > getBuffer returns binary data as Uint8Array
(skip) Valkey: Buffer Operations > getBuffer for non-existent key returns null
(skip) Valkey: Buffer Operations > Really long buffer
(skip) Valkey: Buffer Operations > Buffer with no bytes
(skip) Valkey: Buffer Operations > Buffer with null bytes
(skip) Valkey: Buffer Operations > concurrent getBuffer against large blob
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBufferView key
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBuffer key
(skip) Valkey: Buffer Operations > set and getBuffer with Blob key
(pass) Valkey: Blob argument serialization > in-memory Blob arguments reach the wire with their bytes intact [1.76ms]
209 |       const expected = expect.objectContaining({
210 |         code: "ERR_INVALID_ARG_TYPE",
211 |         message: expect.s
... (truncated)
passes on PR (with fix)
ASAN with fix: 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/unit/buffer-operations.test.ts
bun test v1.4.0 (23619b79a)

test/js/valkey/unit/buffer-operations.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey: Buffer Operations > getBuffer returns binary data as Uint8Array
(skip) Valkey: Buffer Operations > getBuffer for non-existent key returns null
(skip) Valkey: Buffer Operations > Really long buffer
(skip) Valkey: Buffer Operations > Buffer with no bytes
(skip) Valkey: Buffer Operations > Buffer with null bytes
(skip) Valkey: Buffer Operations > concurrent getBuffer against large blob
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBufferView key
(skip) Valkey: Buffer Operations > set and getBuffer with ArrayBuffer key
(skip) Valkey: Buffer Operations > set and getBuffer with Blob key
(pass) Valkey: Blob argument serialization > in-memory Blob arguments reach th
... (truncated)

release with fix: 9 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 665ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[2/124] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 238 extern-C blocks audited
[2/124] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m lol_html v2.7.2 (/workspace/bun/vendor/lolhtml)
�[1m�[92m   Compiling�[0m subtle v2.6.1
�[1m�[92m   Compiling�[0m arrayvec v0.7.6
�[1m�[92m   Compiling�[0m base64 v0.22.1
�[1m�[92m   Compiling�[0m constant_time_eq v0.4.2
�[1m�[92m   Compiling�[0m generic-array v0.14.7
�[1m�[92m   Compiling�[0m arrayref v0.3.9
�[1m�[92m   Compiling�[0m byteorder v1.5.0
�[1m�[92m   Compiling�[0m blake2b_simd v1.0.4
�[1m�[92m   Compiling�[0m crypto-common v0.1.7
�[1m�[92m   Compiling�[0m block-buffer v0.10.4
�[1m�[92m   Compiling�[0m inout v0.1.4
�[1m
... (truncated)
diff hotspot
src/runtime/valkey_jsc/js_valkey_functions.rs |  11 ++-
 test/js/valkey/unit/buffer-operations.test.ts | 130 ++++++++++++++++++++++++++
 2 files changed, 140 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/runtime/valkey_jsc/js_valkey_functions.rs      4      3      0
test/js/valkey/unit/buffer-operations.test.ts      1      3      0

RedisClient accepts Blob for any KeyLike parameter, but the argument
converter read the Blob's in-memory view via shared_view(), which is
empty for a file- or S3-backed store. So

  await redis.set("k", Bun.file("page.html"))

sent SET "k" "" on the wire, resolved "OK", and silently dropped the
file contents. Same for a Bun.file used as a key (became the empty key),
a single-part new Blob([Bun.file(p)]) (shares the file store), and a
nonexistent path (no ENOENT).

The converter now checks needs_to_read_file() / is_s3() on the Blob arm
and throws ERR_INVALID_ARG_TYPE telling the user to pass
await blob.bytes(), matching ServerWebSocket.send and the MySQL binder.
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix verified locally, waiting on CI.

Reproduced with the inline RESP3 recorder against stock bun (sends SET "file" ""); with this change the same call throws ERR_INVALID_ARG_TYPE before any frame is written.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Valkey Blob validation

Layer / File(s) Summary
Validate Blob arguments
src/runtime/valkey_jsc/js_valkey_functions.rs
from_js rejects file-backed and S3-backed Blob values with an invalid-argument error.
Verify Blob serialization
test/js/valkey/unit/buffer-operations.test.ts
Tests verify byte serialization for in-memory and sliced Blobs, and rejection without wire frames for file-backed Blobs. Subsequent valid commands remain functional.

Possibly related PRs

  • oven-sh/bun#36001: Both changes modify Blob storage handling during argument conversion.
  • oven-sh/bun#36032: Both changes reject file-backed and S3-backed Blobs in separate APIs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely states that file-backed Blob arguments are rejected instead of serialized as empty strings.
Description check ✅ Passed The description explains the cause, fix, affected cases, and verification results using both required template sections.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/unit/buffer-operations.test.ts`:
- Around line 209-224: Extend the Blob conversion tests around the existing
file-backed cases to assert ERR_INVALID_ARG_TYPE for both file-backed and
S3-backed Blobs. Add a hermetic Bun.S3Client.file("some-key") case, verify the
thrown error code for each storage type, and retain the existing no-frame
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 24b6ee62-aa67-456a-9dc5-c1c87a84add8

📥 Commits

Reviewing files that changed from the base of the PR and between 074656d and ec24f2b.

📒 Files selected for processing (2)
  • src/runtime/valkey_jsc/js_valkey_functions.rs
  • test/js/valkey/unit/buffer-operations.test.ts

Comment thread test/js/valkey/unit/buffer-operations.test.ts Outdated
Comment thread src/runtime/valkey_jsc/js_valkey_functions.rs Outdated

@claude claude Bot left a comment

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.

LGTM — small, focused input-validation guard following the existing ServerWebSocket pattern.

What was reviewed:

  • Verified from_js is the single shared converter for all prototype methods and .send(), so the guard covers every argument position.
  • Confirmed throw_invalid_arguments → JscError::INVALID_ARG_TYPE → ERR_INVALID_ARG_TYPE, matching the test assertion.
  • Checked BlobOrStringOrBuffer::slice() calls shared_view() on the Blob arm, confirming the empty-string bug the guard prevents.
  • Tests are hermetic (local RESP3 recorder on port 0, no real server), assert both the throw and that no frame reaches the wire, and include a regression guard for in-memory Blobs.
Extended reasoning...

Overview

Adds a 6-line guard in js_valkey_functions.rs::from_js that rejects file- and S3-backed Blob arguments to RedisClient with ERR_INVALID_ARG_TYPE, instead of letting them fall through to shared_view() (which is empty for those stores) and serialize as a zero-length bulk string. Adds two dockerless tests in buffer-operations.test.ts using an in-process RESP3 recorder.

Security risks

None. This is a validation guard that fails closed — it turns silent data loss into a synchronous throw. No new parsing of untrusted input.

Level of scrutiny

Low-medium. The Rust change is a small, additive guard on a single helper, following the exact pattern already established in ServerWebSocket.rs:208-211. I traced:

  • BlobOrStringOrBuffer::Blob(Box<Blob>) is the matched variant; needs_to_read_file() / is_s3() are inherent Blob methods checking store::Data::File / store::Data::S3.
  • throw_invalid_arguments → to_invalid_arguments → JscError::INVALID_ARG_TYPE.fmt(), so the test's code: "ERR_INVALID_ARG_TYPE" assertion holds.
  • slice() on the Blob arm calls blob.shared_view(), confirming the root cause.
  • The throw is synchronous (before send_cmd), so expect(() => ...).toThrow() is the right shape.

Other factors

Tests are well-constructed: hermetic local Bun.listen({port: 0}) recorder, tempDir from harness, resources released in finally before assertions, autoReconnect: false. The negative test asserts the exact error code and message and proves no wire frame was produced (wire equals only the trailing sentinel SET after ok). The positive test guards against regressing in-memory / sliced Blobs. S3 case constructs a lazy S3Client#file() — no network, throw happens in argument conversion. All prior review threads (comment-cop on the explanatory comment, coderabbit on S3 coverage) are resolved by follow-up commits.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants