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
4 changes: 2 additions & 2 deletions src/bun.js/jsc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,11 @@

/// According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date,
/// maximum Date in JavaScript is less than Number.MAX_SAFE_INTEGER (u52).
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)));

Check warning on line 247 in src/bun.js/jsc.zig

View check run for this annotation

Claude / Claude Code Review

toJSTime clamp upper bound collides with init_timestamp sentinel

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
Comment on lines 243 to +247

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.

}

pub const MAX_SAFE_INTEGER = 9007199254740991;
Expand Down
55 changes: 55 additions & 0 deletions test/js/bun/util/bun-file-negative-mtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { closeSync, openSync, utimesSync } from "fs";
import { isWindows, tempDir } from "harness";
import { join } from "path";

// jsc.toJSTime previously used unchecked @intCast to u64 on sec/nsec from
// fstat mtime. A file with mtime before the Unix epoch (negative seconds)
// would trip integerOutOfBounds in the ReadFile thread-pool task when
// resolveSizeAndLastModified stored last_modified. With the fix, the
// timestamp is clamped to 0 instead of crashing.
describe.skipIf(isWindows)("Bun.file with pre-epoch mtime", () => {
test("text() on a path-backed file with negative mtime does not crash", async () => {
using dir = tempDir("bun-file-neg-mtime", { "neg.txt": "hello" });
const path = join(String(dir), "neg.txt");
utimesSync(path, new Date(-12345678), new Date(-12345678));

const f = Bun.file(path);
expect(await f.text()).toBe("hello");
expect(f.lastModified).toBe(0);
});

test("lastModified getter on a file with negative mtime does not crash", async () => {
using dir = tempDir("bun-file-neg-mtime", { "neg.txt": "x" });
const path = join(String(dir), "neg.txt");
utimesSync(path, new Date(-5000), new Date(-5000));

expect(Bun.file(path).lastModified).toBe(0);
});

test("text() on an fd-backed file with negative mtime does not crash", async () => {
using dir = tempDir("bun-file-neg-mtime", { "neg.txt": "from fd" });
const path = join(String(dir), "neg.txt");
utimesSync(path, new Date(-1000), new Date(-1000));

const fd = openSync(path, "r");
try {
const f = Bun.file(fd);
expect(await f.text()).toBe("from fd");
expect(f.lastModified).toBe(0);
} finally {
closeSync(fd);
}
});

test("normal mtime is still reported correctly", async () => {
using dir = tempDir("bun-file-neg-mtime", { "pos.txt": "ok" });
const path = join(String(dir), "pos.txt");
const now = Date.now();
utimesSync(path, new Date(now), new Date(now));

const f = Bun.file(path);
expect(await f.text()).toBe("ok");
expect(Math.abs(f.lastModified - now)).toBeLessThan(2000);
});
});
Loading