Skip to content

blob: clamp pre-epoch/overflow mtime in toJSTime to avoid @intCast panic#29459

Closed
robobun wants to merge 1 commit into
mainfrom
farm/83255ac4/fix-tojstime-negative-mtime
Closed

blob: clamp pre-epoch/overflow mtime in toJSTime to avoid @intCast panic#29459
robobun wants to merge 1 commit into
mainfrom
farm/83255ac4/fix-tojstime-negative-mtime

Conversation

@robobun

@robobun robobun commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a Zig safety-check panic in jsc.toJSTime when fstat reports an mtime before the Unix epoch (negative sec) or far enough in the future to overflow isize * 1000. All four call sites feed stat.mtime() straight into this helper, so any such file crashes Bun.file(path).text() / .lastModified on a thread-pool worker.

Fuzzer fingerprint: b576ba5fca6d3710

Root cause

pub fn toJSTime(sec: isize, nsec: isize) JSTimeType {
    const millisec = @as(u64, @intCast(@divTrunc(nsec, std.time.ns_per_ms)));
    return @as(JSTimeType, @truncate(@as(u64, @intCast(sec * std.time.ms_per_s)) + millisec));
}
  • @intCast(@divTrunc(nsec, ...)) to u64 panics if nsec < 0.
  • sec * std.time.ms_per_s panics on isize overflow.
  • @intCast(sec * 1000) to u64 panics if sec < 0.

Reached from ReadFile.resolveSizeAndLastModified (line 324) → runAsyncWithFD, and from the Blob.lastModified getter. The fuzzer trace showed runAsyncWithFD as the top frame with the two inner callees inlined in that build; the fingerprint differs from #29355 (which fixed the adjacent stat.size @intCast) but sits in the same call chain.

Reproduces deterministically with:

utimesSync(path, new Date(-1000), new Date(-1000));
await Bun.file(path).text(); // panic: integer does not fit in destination type

Fix

Compute the millisecond timestamp in i128 (so isize * 1000 and the nsec addend cannot overflow) and clamp to [0, maxInt(JSTimeType)] before the final cast. JSTimeType is u52 so negative times collapse to 0; this matches the existing storage type rather than changing the field to signed.

How did you verify your code works?

  • New test/js/bun/util/bun-file-negative-mtime.test.ts covers path-backed read, fd-backed read, the lastModified getter, and a positive-mtime sanity check. Panics on the unfixed binary, passes with the fix.
  • bun bd test test/js/bun/util/bun-file-fd-read.test.ts test/js/bun/util/bun-file.test.ts test/js/bun/util/bun-file-read.test.ts test/js/bun/util/bun-stdin-slice.test.ts all pass.
  • objdump of the rebuilt toJSTime shows the remaining safety checks are on i128 arithmetic / post-clamp @intCast, all provably unreachable for isize inputs.

@robobun

robobun commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:11 PM PT - Apr 18th, 2026

@robobun, your commit f267b16 has 2 failures in Build #46308 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29459

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

bun-29459 --bun

@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

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: e00ac037-d97a-4bce-aa1a-1c617472db90

📥 Commits

Reviewing files that changed from the base of the PR and between 8f2519f and f267b16.

📒 Files selected for processing (2)
  • src/bun.js/jsc.zig
  • test/js/bun/util/bun-file-negative-mtime.test.ts

Walkthrough

Updated the toJSTime function to use wider intermediate calculations (i128) with explicit clamping for handling negative timestamps and out-of-range values, while adding comprehensive test coverage for negative mtime behavior in Bun files.

Changes

Cohort / File(s) Summary
Core Time Conversion
src/bun.js/jsc.zig
Updated toJSTime function to compute milliseconds using i128 intermediate type with explicit clamping instead of sequential truncation, changing handling of negative inputs and out-of-range values.
Test Coverage
test/js/bun/util/bun-file-negative-mtime.test.ts
Added new test suite verifying Bun.file behavior with negative mtime (pre-Unix-epoch dates), ensuring correct content retrieval and lastModified clamping to 0.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: clamping pre-epoch/overflow mtime values in toJSTime to prevent @intCast panic, which is the core fix in this PR.
Description check ✅ Passed The description fully addresses both required template sections: 'What does this PR do?' provides comprehensive context on the panic fix with root cause analysis, and 'How did you verify your code works?' details new tests, existing test passes, and objdump verification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix integer overflow in toJSTime for extreme file mtimes #29431 - Also fixes integer overflow in toJSTime for extreme file mtimes in src/bun.js/jsc.zig

🤖 Generated with Claude Code

@robobun

robobun commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29431, which covers this plus the init_timestamp sentinel and the Stat.zig bigint path. Closing in favor of that one; the negative-mtime repro here is already handled by its clamp(..., 0, ...).

@robobun robobun closed this Apr 18, 2026
@robobun robobun deleted the farm/83255ac4/fix-tojstime-negative-mtime branch April 18, 2026 17:05
Comment thread src/bun.js/jsc.zig
Comment on lines 243 to +247
pub const init_timestamp = std.math.maxInt(JSTimeType);
pub const JSTimeType = u52;
pub fn toJSTime(sec: isize, nsec: isize) JSTimeType {
const millisec = @as(u64, @intCast(@divTrunc(nsec, std.time.ns_per_ms)));
return @as(JSTimeType, @truncate(@as(u64, @intCast(sec * std.time.ms_per_s)) + millisec));
const millisec = @as(i128, sec) * std.time.ms_per_s + @divTrunc(nsec, std.time.ns_per_ms);
return @intCast(std.math.clamp(millisec, 0, std.math.maxInt(JSTimeType)));

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 upper bound of the toJSTime clamp (std.math.maxInt(JSTimeType)) is the same value as the init_timestamp sentinel used to mean "last_modified not yet loaded". For any file whose mtime converts to >= 4,503,599,627,370,495 ms (roughly year 144,680 CE, achievable via utimesSync(path, new Date(Number.MAX_SAFE_INTEGER), ...)), toJSTime returns exactly init_timestamp, which is then stored in store.data.file.last_modified. The getLastModified getter re-issues resolveFileStat every time it sees the sentinel, so the caching mechanism never fires -- every .lastModified access issues a fresh stat(2) syscall. Fix by clamping to std.math.maxInt(JSTimeType) - 1, or using a separate bool loaded flag.

Extended reasoning...

Bug: toJSTime clamp collides with init_timestamp sentinel

What the bug is and how it manifests

init_timestamp = std.math.maxInt(JSTimeType) = maxInt(u52) = 4,503,599,627,370,495 is used as a sentinel in Store.File.last_modified (Store.zig:258) to mean "mtime has not yet been loaded from the filesystem". The new toJSTime implementation clamps to exactly this value as its upper bound:

return @intCast(std.math.clamp(millisec, 0, std.math.maxInt(JSTimeType)));

Any file whose mtime maps to >= 4,503,599,627,370,495 ms will have toJSTime return init_timestamp. That clamped value is then written back into store.data.file.last_modified by resolveFileStat (Blob.zig:3303, 3317).

The specific code path

getLastModified (Blob.zig:3067) checks:

if (store.data.file.last_modified == jsc.init_timestamp and !this.isS3()) {
    resolveFileStat(store);
}

Since resolveFileStat stores the clamped init_timestamp back, the condition remains true on every subsequent call. The result is a stat(2) syscall on every single .lastModified access -- the caching mechanism is permanently bypassed for affected files.

Why existing code doesn't prevent it

Before this PR, a far-future mtime would panic via @intCast overflow. The bug was thus unreachable -- the sentinel collision existed conceptually but could never be observed. This PR introduces reachability: the new clamp makes the crash-path survivable, but in doing so creates a value path where the mtime is indistinguishable from the "not loaded" sentinel.

Impact

The impact is a performance regression rather than a correctness issue: .lastModified always returns maxInt(u52) (a reasonable "max future date" value), but the result is never cached. Every access re-issues a stat(2) syscall instead of returning the in-memory value.

Concrete reproducer (step-by-step)

Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991 ms > maxInt(u52) = 4,503,599,627,370,495 ms, so:

  1. utimesSync(path, new Date(Number.MAX_SAFE_INTEGER), new Date(Number.MAX_SAFE_INTEGER)) -- sets mtime to a value > maxInt(u52)
  2. const f = Bun.file(path) -- creates a Blob; last_modified starts at init_timestamp
  3. f.lastModified -- getLastModified sees last_modified == init_timestamp, calls resolveFileStat
  4. resolveFileStat calls toJSTime(sec, nsec) where sec * 1000 > maxInt(u52); clamp returns maxInt(u52) = init_timestamp
  5. last_modified is set to init_timestamp -- unchanged from the sentinel
  6. Next f.lastModified call -- last_modified == init_timestamp again; resolveFileStat fires again
  7. Steps 4-6 repeat indefinitely; every .lastModified access issues a stat(2) syscall

Addressing the "implausible" objection

While year 144,680 CE timestamps don't appear on real filesystems organically, utimesSync with any Date > ~year 144,000 CE is reachable from JS. Test harnesses, archive tools, and synthetic filesystem generators routinely set unusual timestamps. More importantly, a code fix that introduces a latent sentinel collision is a maintenance hazard regardless of practical frequency.

Fix

Change the clamp upper bound by one:

return @intCast(std.math.clamp(millisec, 0, std.math.maxInt(JSTimeType) - 1));

Or use a dedicated bool loaded flag in Store.File to avoid using an in-band sentinel entirely.

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.

1 participant