Skip to content

Bun.randomUUIDv7: reject timestamps >= 2^48 and NaN instead of truncating - #34021

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/2cd82965/uuidv7-timestamp-range
Jul 14, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/2cd82965/uuidv7-timestamp-range

Conversation

@robobun

@robobun robobun commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const tsOf = u => parseInt(u.replaceAll('-', '').slice(0, 12), 16);
console.log(tsOf(Bun.randomUUIDv7('hex', 2 ** 48)));      // 0 (truncated)
console.log(tsOf(Bun.randomUUIDv7('hex', 2 ** 53 - 1)));  // 281474976710655 (truncated)
console.log(tsOf(Bun.randomUUIDv7('hex', NaN)));          // 0
console.log(Bun.randomUUIDv7('hex', 2 ** 48) < Bun.randomUUIDv7('hex')); // true
console.log(tsOf(Bun.randomUUIDv7(undefined, 123456789))); // Date.now(), not 123456789

Cause

The UUIDv7 unix_ts_ms field is 48 bits (RFC 9562 section 5.7). UUID7::init writes only the low 6 bytes of the timestamp, but the range check in bun_random_uuid_v7 used the IntegerRange default max of Number.MAX_SAFE_INTEGER (2^53-1). So [2^48, 2^53-1] was accepted and then truncated mod 2^48: 2**48 encoded as epoch 0 and sorted before every real UUID. The RangeError message also advertised the unreachable 2^53-1 bound.

validate_integer_range maps NaN to the passed default (0), so NaN produced a timestamp-0 UUID instead of throwing.

The Date argument path (date.max(0.0) as u64) bypassed all validation, so new Date(8.64e15) truncated and new Date(NaN) / pre-epoch dates silently encoded 0.

The timestamp_value selector required the first argument to be a string before reading arguments.ptr[1], so Bun.randomUUIDv7(undefined, ts) (valid per the encoding?: ... type signature) silently ignored ts and used the current time.

UUID7::next (from #34022) did ts.wrapping_add(1) on 12-bit counter rollover with no 48-bit clamp: after <=4096 calls pinned at 2**48-1, the bumped timestamp reaches 2**48 and encodes as epoch 0 (same symptom via the internal rollover path).

Fix

  • src/runtime/webcore/Crypto.rs: set max: (1 << 48) - 1 on the IntegerRange; reject NaN with ERR_OUT_OF_RANGE before validate_integer_range's NaN-to-default mapping; validate extracted Date timestamps against [0, 2^48-1]; route arguments.ptr[1] whenever arguments.len > 1.
  • src/jsc/uuid.rs: clamp the counter-rollover timestamp bump at (1<<48)-1.

Verification

bun bd test test/js/bun/util/randomUUIDv7.test.ts   # 18 pass

The timestamp range validation block covers rejection of 2**48, 2**53-1, NaN, new Date(-1), new Date(2**48), new Date(8.64e15), and Invalid Date with RangeError across all three call shapes (("hex", ts), (undefined, ts), (ts)); acceptance and exact encoding of 2**48-1 (subprocess, since it parks the process-global timestamp); a 5000-iteration subprocess asserting counter rollover at 2**48-1 does not wrap to epoch 0; and an assertion that the RangeError message advertises 281474976710655 rather than 9007199254740991.

The pre-existing test("timestamp") was dropped: it compared (undefined, Date.now()) to Date.now() within 32ms, which cannot distinguish a dropped argument from an honored one, and cannot be rewritten in-process post-#34022 since the process-global timestamp never moves backward. Its intended coverage lives in the subprocess accept test and the rejection matrix.


[review] gate passed · iteration 5 · 3 files touched

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/util/randomUUIDv7.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f54393657)

test/js/bun/util/randomUUIDv7.test.ts:
(pass) randomUUIDv7 > basic [4.28ms]
(pass) randomUUIDv7 > base64 format [2.23ms]
019f5a24bee7755ab60a89259e18b1c4
(pass) randomUUIDv7 > buffer output encoding [2.82ms]
(pass) randomUUIDv7 > monotonic [28.63ms]
(pass) randomUUIDv7 > monotonic across 12-bit counter rollover [390.73ms]
53 |       ["Date(-1)", new Date(-1)],
54 |       ["Date(2**48)", new Date(2 ** 48)],
55 |       ["Date(8.64e15)", new Date(8.64e15)],
56 |       ["Invalid Date", new Date(NaN)],
57 |     ])("rejects %s", (_, ts) => {
58 |       expect(() => Bun.randomUUIDv7("hex", ts)).toThrow(RangeError);
                                                     ^
error: expect(received).toThrow(expected)

Expected c
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (6e2a54c63)

test/js/bun/util/randomUUIDv7.test.ts:
(pass) randomUUIDv7 > basic [0.13ms]
(pass) randomUUIDv7 > base64 format [0.04ms]
019f5a24ce08768f9fb7d2a1f1ae9a8f
(pass) randomUUIDv7 > buffer output encoding [0.09ms]
(pass) randomUUIDv7 > monotonic [0.34ms]
(pass) randomUUIDv7 > monotonic across 12-bit counter rollover [2.22ms]
(pass) randomUUIDv7 > timestamp range validation > rejects 2**48 [0.14ms]
(pass) randomUUIDv7 > timestamp range validation > rejects 2**53 - 1 [0.02ms]
(pass) randomUUIDv7 > timestamp range validation > rejects NaN [0.02ms]
(pass) randomUUIDv7 > timestamp range validation > rejects Date(-1)
(pass) randomUUIDv7 > timestamp range validation > rejects Date(2**48)
(pass) randomUUIDv7 > timestamp range validation > rejects Date(8.64e15)
(pass) randomUUIDv7 > timestamp range validation > rejects Invalid Date
(pass) randomUUIDv7 > timestamp range validation > RangeError message advertises the 48-bit bound [0.08ms]
(pass) randomUUIDv7 > timestamp range validation > accepts 2**48 - 1 (max 48-bit value) [11.74ms]
(pass) randomUUIDv7 > timestamp range validation > counter rollover at 2**48-1 clamps instead of wrapping to epo
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/util/randomUUIDv7.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f54393657)

test/js/bun/util/randomUUIDv7.test.ts:
(pass) randomUUIDv7 > basic [3.93ms]
(pass) randomUUIDv7 > base64 format [2.20ms]
019f5a2572c973d6b70714d87bf942d1
(pass) randomUUIDv7 > buffer output encoding [2.81ms]
(pass) randomUUIDv7 > monotonic [28.03ms]
(pass) randomUUIDv7 > monotonic across 12-bit counter rollover [391.95ms]
(pass) randomUUIDv7 > timestamp range validation > rejects 2**48 [8.00ms]
(pass) randomUUIDv7 > timestamp range validation > rejects 2**53 - 1 [1.74ms]
(pass) randomUUIDv7 > timestamp range validation > rejects NaN [1.98ms]
(pass) randomUUIDv7 > timestamp range validation > rejects Date(-1) [1.42ms]
(pass) randomUUIDv7 > timestamp range validation > rejects Date(2**48) [1.31ms]
(pass) randomUUIDv7
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 678ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/jsc/uuid.rs                       |  2 +-
 src/runtime/webcore/Crypto.rs         | 19 ++++++-
 test/js/bun/util/randomUUIDv7.test.ts | 93 +++++++++++++++++++++++++++++++----
 3 files changed, 101 insertions(+), 13 deletions(-)

gate history · 5 passed · 1 rejected · iteration 5

evidence per changed file
file                                   reads  edits  tests
src/jsc/uuid.rs                            2      1      0
src/runtime/webcore/Crypto.rs              5      6      0
test/js/bun/util/randomUUIDv7.test.ts      4      7      0

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 236abaf3-05e7-44dc-867d-62045ebc4233

📥 Commits

Reviewing files that changed from the base of the PR and between 6e2a54c and f543936.

📒 Files selected for processing (1)
  • test/js/bun/util/randomUUIDv7.test.ts
💤 Files with no reviewable changes (1)
  • test/js/bun/util/randomUUIDv7.test.ts

Walkthrough

Changes

UUIDv7 timestamp validation

Layer / File(s) Summary
Enforce and test the 48-bit timestamp range
src/runtime/webcore/Crypto.rs, test/js/bun/util/randomUUIDv7.test.ts
Timestamp argument selection and validation enforce the UUIDv7 48-bit limit, reject invalid numeric and Date values with RangeError, and test boundary behavior across supported call forms.
Saturate UUIDv7 counter rollover
src/jsc/uuid.rs, test/js/bun/util/randomUUIDv7.test.ts
Counter rollover saturates at the maximum 48-bit timestamp instead of wrapping to epoch 0, with subprocess coverage for the upper boundary.
🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR changes UUIDv7 timestamp validation, but linked issue #39 is about Node.js build output and bundling behavior. Either link the relevant UUIDv7 issue or add changes that address #39's Node.js build/runtime requirements.
Out of Scope Changes check ⚠️ Warning The UUIDv7 timestamp and rollover changes are unrelated to the linked Node.js build-output issue. Remove the UUIDv7 work from this PR or relink it to an issue that specifically covers randomUUIDv7 timestamp validation.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: rejecting out-of-range and NaN timestamps for randomUUIDv7.
Description check ✅ Passed It explains the change, cause, fix, and verification, though it uses custom headings instead of the exact template.

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

@robobun

robobun commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:59 PM PT - Jul 12th, 2026

❌ @autofix-ci[bot], your commit f543936 has 1 failures in Build #72410 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34021

That installs a local version of the PR into your bun-34021 executable, so you can run:

bun-34021 --bun

@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 — the 48-bit bound is correctly derived from RFC 9562, the Date/NaN/number paths now agree, and the boundary (2^48-1 accepted, 2^48 rejected) is tested. The inline nit is test-coverage only; the runtime change is sound.

Extended reasoning...

Overview

Tightens timestamp validation in bun_random_uuid_v7 (src/runtime/webcore/Crypto.rs) so all three input paths — number, Date, and the NaN edge case — enforce the same [0, 2^48-1] range that UUID7::init can actually encode. Previously the number path accepted up to 2^53-1 and truncated mod 2^48, the Date path bypassed validation entirely via .max(0.0) as u64, and NaN fell through validate_integer_range's NaN→default mapping to 0. Adds a timestamp range validation describe block covering the boundary and rejection cases.

Security risks

None. This narrows accepted input (fail-closed), doesn't touch entropy generation, and the only new code paths are throw_range_error returns.

Level of scrutiny

Low-to-moderate. Small, self-contained change to a Bun-specific utility API with a clear RFC-cited rationale. I verified: (1i64 << 48) - 1 is exactly representable as f64 (< 2^53), so the > MAX_TIMESTAMP as f64 comparison is exact; the post-check date as u64 cast is lossless in [0, 2^48-1]; and u64::try_from(i64).unwrap() after min: 0 cannot panic. The Date path now matches the number path's existing min: 0 semantics (negative numbers already threw), so rejecting pre-epoch Dates is a consistency fix rather than a new design choice.

Other factors

The one inline finding is a test-quality nit (missing Date(-1) row and bare .toThrow()) — worth addressing but not a correctness issue with the fix. The PR description states 14 tests pass under bun bd test. This is a user-visible behavior change (silent-garbage → throw), but converting silent truncation that breaks UUIDv7's sort-order guarantee into a RangeError is unambiguously the right direction.

Comment thread test/js/bun/util/randomUUIDv7.test.ts

@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 — both earlier nits addressed (Date(-1) row added, .toThrow(RangeError) on every case).

What was reviewed:

  • The three new throw paths (Date-range, explicit NaN, IntegerRange.max) — each covered by the rejection matrix; 2**48-1 boundary accepted on both number and Date paths.
  • MAX_TIMESTAMP as f64 is exactly representable (< 2^53), so the Date upper-bound comparison is exact.
  • as_number() guarded by is_number(); u64::try_from(..).unwrap() is infallible after the [0, 2^48-1] range check.
Extended reasoning...

Overview

Tightens input validation in bun_random_uuid_v7 (src/runtime/webcore/Crypto.rs): introduces MAX_TIMESTAMP = (1<<48)-1, validates the Date-argument path against [0, 2^48-1] (was date.max(0.0) as u64 with no upper bound), rejects numeric NaN explicitly before it reaches validate_integer_range's NaN→default fallback, and sets IntegerRange.max so the RangeError message matches what's actually encodable. Tests add a timestamp range validation block covering the accept boundary, a 7-case rejection matrix across both call shapes, and an assertion that the error message advertises 281474976710655 rather than 9007199254740991.

Security risks

None. The change only adds stricter validation on a user-provided timestamp before it's packed into 6 bytes of a UUID. No new allocations, no pointer arithmetic, no changes to the entropy path or the crypto primitives themselves.

Level of scrutiny

Low-to-medium. ~25 lines of Rust in a single function, plus tests. The logic is straightforward range checking using existing helpers (throw_range_error, validate_integer_range). This is a Bun-native API (not Node/Web compat), so tightening validation to reject previously-silently-truncated inputs is the right call and doesn't need to match an external spec beyond RFC 9562's 48-bit field width, which it does.

Other factors

  • I left two test-quality nits on the previous revision (missing Date(-1) coverage for the date < 0.0 clause; bare .toThrow()). Both were applied in 2d56369 and are visible in the current diff.
  • Checked that is_number() guards as_number() (per the exception-safety guidance for non-throwing accessors on user values), that (1i64 << 48) - 1 is exactly representable as f64 so the Date comparison has no rounding edge, and that the post-validation date as u64 / u64::try_from(...).unwrap() casts are infallible given the validated range.
  • The one user-visible behavior change beyond the bug fix — pre-epoch Date now throws instead of clamping to timestamp 0 — is intentional, called out in the PR description, and now has a dedicated test row.
  • Bug-hunting system found nothing.

@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 `@src/runtime/webcore/Crypto.rs`:
- Around line 219-252: Extract the duplicated RangeErrorOptions construction
from the timestamp validation logic into a named local helper or reusable value
near the MAX_TIMESTAMP declaration. Update both the invalid-Date branch and the
NaN branch to use it, preserving the existing min, max, field_name, and default
values.
🪄 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: b5fdd7e8-ab09-48d7-a445-e8f2d3077761

📥 Commits

Reviewing files that changed from the base of the PR and between 2176d84 and 2d56369.

📒 Files selected for processing (2)
  • src/runtime/webcore/Crypto.rs
  • test/js/bun/util/randomUUIDv7.test.ts

Comment thread src/runtime/webcore/Crypto.rs
Comment thread src/runtime/webcore/Crypto.rs

@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 — all prior feedback addressed (Date(-1) row, .toThrow(RangeError), (undefined, ts) routing + coverage, range_opts dedup).

What was reviewed:

  • 48-bit bound: (1<<48)-1 accepted/encoded exactly; 2**48, NaN, Invalid Date, pre-epoch/over-range Date all reject via the same RangeErrorOptions.
  • Arg routing after dropping the !encoding_value.is_undefined() guard — traced (), (ts), ("hex"), ("hex", ts), (undefined, ts), ("hex", undefined); no regression, and arguments_undef::<2>().len reflects actual arg count so the len == 1 single-arg arm still fires.
  • date as u64 cast is safe post-validation (finite, [0, 2^48-1], integer-valued); Infinity still rejected by the existing validate_integer_range path.
Extended reasoning...

Overview

Tightens Bun.randomUUIDv7 timestamp validation to the RFC 9562 §5.7 48-bit unix_ts_ms field width, and fixes a pre-existing arg-routing bug that dropped the timestamp for the (undefined, ts) overload. Two files: ~20 net Rust lines in src/runtime/webcore/Crypto.rs (a MAX_TIMESTAMP constant, a shared range_opts, explicit Date/NaN range checks, and a one-token guard change on the timestamp_value selector) and ~45 test lines adding a boundary-accept case, a 7-entry × 3-call-shape rejection matrix asserting RangeError, an error-message assertion on the advertised bound, and a rewrite of the pre-existing test("timestamp") to use a fixed 2021 timestamp so it can no longer pass coincidentally against Date.now().

Security risks

None. This is input validation on a UUID helper; no auth, crypto primitives, filesystem, or network involved. The change fails closed (throws) where it previously silently produced wrong output.

Level of scrutiny

Low–medium. Small, self-contained validation fix on a leaf utility API. The user-visible behavior change — inputs in [2^48, 2^53-1], NaN, and out-of-range/invalid Dates now throw instead of silently truncating to a wrong timestamp — is unambiguously a bug fix (silent truncation broke UUIDv7's sort-order guarantee), not an API design decision. The (undefined, ts) routing fix aligns runtime behavior with the published .d.ts signature.

Other factors

This is my third pass on the PR. Both earlier rounds of inline feedback (test-quality nits at 2d56369; the (undefined, ts) routing gap at 5103558) and CodeRabbit's dedup suggestion (4d2be91) are all applied and marked resolved. The bug-hunting system found nothing on the current revision. I re-traced every call shape through the new timestamp_value selector against arguments_undef::<2>() semantics (.len = actual arg count capped at 2) and confirmed no existing form regresses. The float→u64 cast on the Date path is sound after the new finite/[0, 2^48-1] guard. Test coverage now exercises every clause of the new validation across all three overloads.

@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 — both earlier review rounds are addressed and all threads are resolved.

What was reviewed:

  • 48-bit bound, NaN, and Date validation paths in bun_random_uuid_v7; each new throw_range_error site is covered by a .toThrow(RangeError) case including Date(-1).
  • Arg-routing change to arguments.len > 1: traced ("hex"), ("hex", undefined), (undefined, ts), and single-arg number/Date forms — all route correctly.
  • Rewritten test("timestamp") now uses a fixed 2021 ts so it actually detects a dropped argument; accept/reject matrices cover all three call shapes.
Extended reasoning...

Overview

Two files: src/runtime/webcore/Crypto.rs (the bun_random_uuid_v7 host function) and test/js/bun/util/randomUUIDv7.test.ts. The Rust change caps the accepted timestamp at (1 << 48) - 1 to match the RFC 9562 §5.7 field width, rejects NaN before it reaches validate_integer_range's NaN→default mapping, validates extracted Date timestamps against the same range (replacing the old .max(0.0) as u64 clamp), and fixes the timestamp_value selector so (undefined, ts) reads ptr[1]. A shared range_opts local deduplicates the two new throw_range_error sites per CodeRabbit feedback.

Security risks

None. This is input validation on a UUID-generation utility; tightening the accepted range and rejecting NaN/invalid Dates strictly reduces the space of accepted inputs. No auth, crypto primitives, or permissions are touched (despite the file name, this is the randomUUIDv7 helper, not key material).

Level of scrutiny

Low-to-moderate. The change is ~19 lines of Rust in a single function plus ~45 lines of tests. It's a focused input-validation fix with a clear RFC citation for the bound. The one user-visible behavior change beyond the range cap — pre-epoch Date and NaN now throw instead of silently encoding timestamp 0 — is the obviously-correct choice (silent truncation broke sort order, which is UUIDv7's whole point).

Other factors

I've reviewed this PR twice already. The first round asked for a Date(-1) row and .toThrow(RangeError) — applied in 2d56369. The second flagged the pre-existing (undefined, ts) arg-routing bug — fixed in 5103558 with the selector guard simplified to arguments.len > 1 and the coincidentally-passing test("timestamp") rewritten to a fixed past timestamp. CodeRabbit's dedup suggestion was applied in 4d2be91. All inline threads are resolved. The PR description includes fail-on-main / pass-on-PR evidence for both ASAN debug and release builds (15 tests pass). I re-traced the new selector against ("hex"), ("hex", undefined), (ts), and ({}, ts) — no regressions. No CODEOWNERS on this path.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green: bun bd test test/js/bun/util/randomUUIDv7.test.ts passes 15/15 locally on both debug-ASAN and release, and the gate's fail-before/pass-after is clean.

CI reds on builds 72157 and 72165 are unrelated to this change:

  • test/js/bun/http/serve-http3.test.ts segfault on debian 13 x64-baseline (HTTP/3 stream reset; this diff does not touch HTTP)
  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js JSC assertion !scope.exception() || !result in JSObject::getOwnPropertyDescriptor on debian 13 x64-asan (worker termination; already tracked separately)

Remaining yellows are known install/napi flakes that passed on retry. Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflcits and rebase

…ting

The UUIDv7 unix_ts_ms field is 48 bits (RFC 9562 section 5.7). UUID7::init
writes only the low 6 bytes of the timestamp, but the range check used the
IntegerRange default max of Number.MAX_SAFE_INTEGER (2^53-1). Timestamps in
[2^48, 2^53-1] were accepted and truncated mod 2^48, so 2**48 encoded as
epoch 0 and sorted before every real UUID. The RangeError message also
advertised the unreachable 2^53-1 bound.

validate_integer_range maps NaN to the default (0), so NaN produced a
timestamp-0 UUID. The Date path (date.max(0.0) as u64) bypassed all
validation, so new Date(8.64e15), Invalid Date, and pre-epoch Dates
silently encoded 0 or truncated.

The timestamp_value selector also required the first argument to be a
string before reading arguments.ptr[1], so randomUUIDv7(undefined, ts)
silently ignored ts and used the current time.

Cap the range at 2^48-1, reject NaN and out-of-range Dates with
ERR_OUT_OF_RANGE, and route arguments.ptr[1] whenever arguments.len > 1.
Rejection tests run in-process (they throw before touching the
process-global UUID_V7_LAST_TIMESTAMP); the 2^48-1 accept test runs in a
subprocess to avoid parking the global at year 10889.
@robobun
robobun force-pushed the farm/2cd82965/uuidv7-timestamp-range branch from 00a8f8e to 8dd20db Compare July 13, 2026 05:51
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (8dd20db). #34022 made UUID_V7_LAST_TIMESTAMP a never-goes-backward process global, so the 2**48-1 accept test now runs in a subprocess (following that PR's pattern). The rejection/error-message tests stay in-process since they throw before UUID7::init and never touch the global. 18/18 pass locally.

Comment thread test/js/bun/util/randomUUIDv7.test.ts
Comment thread src/runtime/webcore/Crypto.rs
robobun and others added 2 commits July 13, 2026 06:14
UUID7::next did ts.wrapping_add(1) on 12-bit counter rollover with no
48-bit bound. After <=4096 calls at 2^48-1 the bumped timestamp reached
2^48, which UUID7::init then encodes as six zero bytes: the wrap-to-epoch-0
symptom this PR fixes for direct input, reached via the internal rollover
path instead. Clamp the bump at (1<<48)-1.

Also dropped the vacuous test('timestamp') (it compared (undefined,
Date.now()) to Date.now() within 32ms, which cannot distinguish a dropped
argument from an honored one). Coverage for (undefined, ts) lives in the
2^48-1 subprocess accept test and the rejection matrix.
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Post-rebase CI (build 72410): randomUUIDv7.test.ts passes on every lane. The only red is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian 13 x64-asan (the same JSC !scope.exception() || !result assertion in JSObject::getOwnPropertyDescriptor seen on earlier builds, tracked separately and unrelated to this diff). Remaining yellows are install-registry/napi flakes that passed on retry.

Ready for merge.

@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 — all prior review feedback has been applied and the final diff is a focused validation fix.

Checked the three throw sites (Date, NaN, validate_integer_range) reach the same 48-bit bound and error class; MAX_TIMESTAMP as f64 is exact (< 2^53) so the Date comparison admits 2**48-1 and rejects 2**48.
Verified the arguments.len > 1 guard change doesn't regress ("hex", ts) or the single-arg overload — the len == 1 && encoding_value.is_undefined() arm still routes (ts) correctly.
Confirmed the dropped test("timestamp") is fully covered by the rejection matrix's (undefined, ts) row and the 2**48-1 subprocess accept test.
The (ts + 1).min(2^48-1) clamp cannot overflow since ts is already ≤ 2^48-1 at that point.

Extended reasoning...

Overview

Three-file change to Bun.randomUUIDv7: (1) src/runtime/webcore/Crypto.rs tightens timestamp validation to the RFC 9562 §5.7 48-bit unix_ts_ms range — adds max: (1<<48)-1 to the existing IntegerRange, rejects NaN before the NaN→default mapping in validate_integer_range, validates extracted Date values against [0, 2^48-1] instead of .max(0.0)-clamping, and fixes the timestamp_value selector so (undefined, ts) reaches the check. (2) src/jsc/uuid.rs clamps the counter-rollover timestamp bump at 2^48-1 instead of wrapping_add. (3) test/js/bun/util/randomUUIDv7.test.ts adds a 7-row rejection matrix across all three call shapes, a RangeError-message assertion, and two subprocess tests for the boundary accept and the rollover clamp; deletes the pre-existing test("timestamp") which was vacuous (compared Date.now() to Date.now() within 32ms).

Security risks

None. The file is named Crypto.rs but this path is UUID timestamp encoding, not cryptographic primitives — no key material, TLS, auth, or randomness-quality changes. The change strictly tightens input validation (previously-accepted garbage inputs now throw RangeError), which is the safe direction. The date as u64 cast is guarded by is_finite() && >= 0 && <= 2^48-1, and Date internal values are integers per ECMA-262 TimeClip, so no precision loss.

Level of scrutiny

Medium. This is a user-facing behavior change (previously-silent inputs now throw) in a stable Bun API, so it warrants care — but the change is unambiguously a bug fix aligning with the RFC and the type signature, not a design decision. The native diff is ~20 lines of straightforward guard logic reusing existing helpers (throw_range_error, validate_integer_range, RangeErrorOptions). No new allocations, no GC/lifetime concerns, no threading changes.

Other factors

This is my fourth pass on the PR. Every prior comment (mine and CodeRabbit's) has been applied and resolved: .toThrow(RangeError) instead of bare .toThrow(), the Date(-1) row, the hoisted range_opts, the (undefined, ts) routing fix, dropping the vacuous test("timestamp"), and the uuid.rs rollover clamp with its 5000-iteration subprocess test. The gate evidence in the description shows clean fail-before/pass-after on both debug-ASAN and release. Jarred requested a rebase, which was done; the one hunk lost in that rebase was the test("timestamp") rewrite, which I flagged and was resolved by deleting the test (its coverage now lives in the rejection matrix and the subprocess accept test — both of which fail on the pre-fix binary, unlike the original). The new subprocess tests use expect(stderr).toBe(""), which CLAUDE.md discourages, but this matches the existing subprocess tests already on main in the same file from #34022, and each test's real assertion is on stdout content — the stderr check is supplementary.

@Jarred-Sumner
Jarred-Sumner merged commit 55a9f31 into main Jul 14, 2026
78 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/2cd82965/uuidv7-timestamp-range branch July 14, 2026 02:20
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