Skip to content

Bun.file: keep the sign of lastModified for files dated before 1970 - #41748

Open
robobun wants to merge 5 commits into
mainfrom
robobun/f46ee018/bunfile-lastmodified-signed
Open

robobun wants to merge 5 commits into
mainfrom
robobun/f46ee018/bunfile-lastmodified-signed

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.file(path).lastModified wraps modulo 2^52 for a file dated before 1970. A file with mtime 1960-01-01 reports 4503284008170496 (year 144673). fs.statSync(path).mtimeMs reports the correct -315619200000, as Node does.
  • The cause is to_js_time (src/jsc/lib.rs:1548). It computed the millisecond count, cast it to u64, and masked it to 52 bits, because JSTimeType was u64 (a port of Zig's u52). A negative count lost its sign.

Fix

  • JSTimeType is now i64. to_js_time computes in i128, truncates toward zero (like new Date(mtimeMs).getTime()), and saturates to i64. The INIT_TIMESTAMP sentinel keeps its value, so nothing else that reads it changes.
  • The three stat call sites (resolve_file_stat, ReadFile, ReadFileUV) now share one helper, stat_to_js_mtime(&Stat). It widens the timespec through the same timespec_parts that node:fs uses (hoisted to a free function in Stat.rs). On Windows that reads the 32-bit uv_timespec_t.sec as unsigned, as Node does. So lastModified agrees with fs.statSync().mtimeMs on every platform, and a 2040 mtime on Windows no longer wraps negative.
  • Verified: test/js/bun/util/bun-file.test.ts (new test fails on stock bun on Linux and Windows, passes with the fix on both, and passed on macOS in CI). Also ran blob.test.ts, globals.test.js, structured-clone-blob-file.test.ts, bun-serve-file.test.ts, bun-file-read.test.ts, test/js/node/fs/fs.test.ts.

Background

  • A Bun.file() blob is backed by a webcore::File store. Its last_modified field starts at the INIT_TIMESTAMP sentinel (2^52 - 1) and is filled lazily: by stat on the first lastModified read, or by the fstat that runs during a read.
  • On Windows bun_sys::Stat is libuv's uv_stat_t, whose timespec seconds are a C long (32 bits). Node reads that field as unsigned long to push the overflow from 2038 to 2106, at the cost of pre-1970 dates. Bun's node:fs already does the same.
Notes
  • Repro: touch -d @-315619200 neg.js && bun -e 'console.log(Bun.file("neg.js").lastModified, require("fs").statSync("neg.js").mtimeMs)' printed 4503284008170496 -315619200000. With the fix: -315619200000 -315619200000.
  • Sub-millisecond negative times: utimes -0.4996 gives lastModified === -499, equal to stat.mtime.getTime(). The old formula floored the nanosecond part separately and would give -500.
  • Windows probe with the fix (utimes date, mtime.getTime(), stat.mtime.getTime(), lazy lastModified, after-read lastModified): 1960-01-01 -315619200000 3979348096000 3979348096000 3979348096000, 2040-06-01 2222121600000 2222121600000 2222121600000 2222121600000. Before the fix both dates gave a value near 2^52.
  • Not changed: Bun.serve static file routes still send no Last-Modified header for a pre-1970 file. StatHash::hash (src/resolver/fs/stat_hash.rs) clamps a negative mtime to 0 on purpose and omits the header. That is a separate decision.
  • Not changed: a missing file and a non-file Blob still return the sentinel from lastModified. fix(BunFile): lastModified returns 0 for missing files instead of 2^52-1 sentinel #33652 covers that.
  • bun-write.test.js has five tests that time out at 5 s under the debug ASAN build in this container, with and without this diff.

no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/bun-file.test.ts

to_js_time masked the millisecond count to 52 bits and stored it as u64,
so a negative mtime wrapped to about 2^52 (year 144673). JSTimeType is now
i64 and to_js_time truncates toward zero without a mask, so
Bun.file(path).lastModified equals fs.statSync(path).mtime.getTime().
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file.

Or wait 56 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 072394fa-9701-499d-9497-e0699ff0e8ca

📥 Commits

Reviewing files that changed from the base of the PR and between 62838e1 and b7bae1f.

📒 Files selected for processing (5)
  • src/jsc/lib.rs
  • src/runtime/node/Stat.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/blob/read_file.rs
  • test/js/bun/util/bun-file.test.ts

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

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:12 PM PT - Sep 7th, 2026

❌ @Jarred-Sumner, your commit b7bae1f has 5 failures in Build #112240 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41748

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

bun-41748 --bun

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on Linux with bun 1.4.3:

touch -d @-315619200 neg.js
bun -e 'console.log(Bun.file("neg.js").lastModified, require("fs").statSync("neg.js").mtimeMs)'
# before: 4503284008170496 -315619200000
# after:  -315619200000 -315619200000

The new test in test/js/bun/util/bun-file.test.ts fails on stock bun and passes with this branch, on Linux and on Windows x64 (both built and run locally). In CI (build 111948) it passed on every lane, macOS and Windows included.

The current head 6d1267c only moves the Windows widening into the shared timespec_parts (no behavior change). Its CI run (build 112020) is red for reasons outside this diff: test-crypto-dh-leak.js on debian x64-asan (red on main), Windows x64 test agents that failed to provision, and a darwin aarch64 shard that failed before the runner started. Every lane that ran tests is green.

@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 — focused fix with a clear mechanism and both stat paths now sharing one helper.

What was reviewed:

  • to_js_time rewrite: i128 total-ns then truncate-toward-zero matches new Date(mtimeMs).getTime(); checked positive inputs give identical results to the old formula.
  • INIT_TIMESTAMP sentinel: same numeric value as before, so the == INIT_TIMESTAMP guard and as f64 fallback in get_last_modified are unaffected.
  • Windows sec as i32 as u32 as i64 cast matches Stat.rs timespec_parts; stat_mtime already sign-extends from i32, so the round-trip is lossless.
  • Test uses tempDir/isWindows from harness, exercises both the lazy-stat and fstat-during-read paths, and gates the negative-value assertion off Windows correctly.
Extended reasoning...

Overview

This PR fixes Bun.file(path).lastModified for files with mtimes before the Unix epoch. The root cause was JSTimeType being u64 (a port of Zig's u52) with a 52-bit mask in to_js_time, which wrapped negative millisecond values into huge positives. The fix changes JSTimeType to i64, rewrites to_js_time to compute total nanoseconds in i128 and truncate toward zero (matching new Date(mtimeMs).getTime() semantics), and consolidates the three stat→mtime call sites (resolve_file_stat, POSIX ReadFile, Windows ReadFileUV) into one shared stat_to_js_mtime helper. On Windows the helper reinterprets the 32-bit uv_timespec_t.sec as unsigned to match what node:fs's timespec_parts already does, so lastModified agrees with fs.statSync().mtimeMs on every platform.

Security risks

None. This is pure arithmetic on stat timestamps with no parsing of untrusted input, no allocation, no syscall changes, and no new code paths reachable from user input beyond what already existed. The i128 intermediate and explicit clamp to i64 range make overflow impossible.

Level of scrutiny

Low-to-medium. The change is small (~40 lines net), mechanically verifiable, and the PR description traces the exact wrong value (4503284008170496) to the specific mask. I verified the sentinel value is numerically preserved (so the == INIT_TIMESTAMP lazy-stat guard and the as f64 fallback in get_last_modified still work), that the new formula gives identical results to the old one for non-negative inputs, and that the Windows cast chain matches the existing timespec_parts in src/runtime/node/Stat.rs. All call sites of to_js_time, JSTimeType, and INIT_TIMESTAMP were grepped — nothing else depends on the old unsigned semantics.

Other factors

The test is added to the correct existing file (bun-file.test.ts), uses harness helpers (tempDir, isWindows), covers both the lazy-stat and fstat-during-read code paths, and correctly gates the "actually negative" assertion off Windows where Node's own unsigned-seconds behavior means pre-1970 dates wrap. No CODEOWNERS cover the touched paths. The consolidation of three inline to_js_time calls into one helper is exactly what REVIEW.md asks for ("fix the whole class"; "the second time a block appears, extract a named helper"). Bug hunter ran to dry_streak with no findings.

Comment thread src/jsc/lib.rs Outdated
Comment thread src/runtime/webcore/Blob.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 — focused signedness fix that routes all three lastModified stat sites through the same timespec_parts helper node:fs already uses.

Checked every consumer of JSTimeType/INIT_TIMESTAMP — the sentinel value is unchanged and all readers go through as f64 or equality, so the u64→i64 flip is transparent.
Traced the Windows path: stat_mtime sign-extends the 32-bit uv_timespec_t.sec to i64, then timespec_parts narrows back to i32 before the unsigned reinterpret, so both pre-1970 and post-2038 dates match fs.Stats.mtimeMs.
Confirmed to_js_time's i128 total-ns division truncates toward zero (matching Date#getTime()) rather than the old per-part floor.
Test covers both the lazy-stat and fstat-during-read paths and branches the raw-date assertion on isWindows only.

Extended reasoning...

Overview

The PR fixes Bun.file().lastModified for files with mtime before the Unix epoch. The old to_js_time cast a signed millisecond count to u64 and masked to 52 bits, wrapping negatives to huge positives. The fix changes JSTimeType to signed i64, computes total nanoseconds in i128, divides to ms (truncating toward zero), and clamps. The private timespec_parts helper in Stat.rs (which already encodes Node's Windows unsigned-32-bit-seconds semantics for fs.Stats) is lifted to pub(crate) and reused by a rewritten stat_to_js_mtime, and both open-coded call sites in read_file.rs (POSIX ReadFile::do_read_loop and Windows ReadFileUV::on_file_open) now route through it. A new test in bun-file.test.ts utimes a file to 1960 and asserts lastModified equals statSync().mtime.getTime() on both code paths.

Security risks

None. This is arithmetic on stat timestamps for a read-only property; no parsing of untrusted input, no auth, no allocation sizing.

Level of scrutiny

Moderate — cross-platform #[cfg]-gated code and a type change with multiple consumers. I grep'd every use of JSTimeType, INIT_TIMESTAMP, and to_js_time: the sentinel keeps its numeric value (1<<52)-1, and all readers either compare for equality (Blob.rs:2080) or cast to f64 (Blob.rs:2085, Blob.rs:2094), so signedness is transparent. On Windows, bun_sys::stat_mtime sign-extends the 32-bit libuv sec to i64; timespec_parts then narrows to i32 before the as u32 reinterpret, so the round-trip preserves Node's wrap for both pre-1970 (→ far future) and post-2038 (→ correct positive) dates. The i128 total-ns approach fixes the sub-ms rounding edge the PR notes (old code floored the nsec term separately).

Other factors

The change follows REVIEW.md's "fix the whole class" — all three stat→mtime sites now share one helper that inherits node:fs's already-vetted platform widening, and the dead PosixStat::init(&stat).mtime() route is removed. The test is in the correct existing file, uses tempDir from harness with using, exercises both the lazy-stat and fstat-during-read paths, and narrows the platform carve-out to a single assertion rather than skipping. Two commits landed after the prior review addressing bot feedback (the current diff reflects the consolidation into timespec_parts rather than a duplicated cfg split). No outstanding CHANGES_REQUESTED reviews.

@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.

Code review found no issues

No high-confidence issues detected in this change.

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