Skip to content

process.env.TZ: accept POSIX forms (':path', '/etc/localtime', 'STD[+-]hh') - #35594

Closed
robobun wants to merge 6 commits into
mainfrom
farm/74e37e3e/tz-posix-and-file-path
Closed

robobun wants to merge 6 commits into
mainfrom
farm/74e37e3e/tz-posix-and-file-path

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #8060. Fixes #3649.

Repro

$ TZ=MSK-3 bun -e 'console.log(new Date().getTimezoneOffset())'
0                    # Node prints -180

$ TZ=:/etc/localtime bun -e 'console.log(new Date().toString())'
# always "GMT+0000" even when /etc/localtime points at a non-UTC zone

Cause

JSC's DateCache resolves the default time zone purely by name: it feeds the WTF override (or ICU's ucal_getHostTimeZone string) through intlResolveTimeZoneID, and when that lookup fails it falls back to a default-constructed TimeZone, i.e. UTC. V8 hits the same ICU path but keeps the SimpleTimeZone ICU builds from libc's timezone offset, so Node still reports the right getTimezoneOffset() for POSIX TZ specs even when Intl cannot name the zone.

Bun's startup and process.env.TZ setter passed the raw value straight to WTF::setTimeZoneOverride, which only accepts names ICU's ucal_getCanonicalTimeZoneID recognises. So :/etc/localtime, /usr/share/zoneinfo/..., and STD[+-]hh POSIX specs all collapsed to UTC.

Fix

Normalize the TZ value on the Bun side before installing the override (Bun::setTimeZoneFromEnvValue, shared by the startup JSGlobalObject__setTimeZone path and the runtime process.env.TZ setter):

  • strip a leading : (the glibc convention);
  • on POSIX, realpath() an absolute path and take the /zoneinfo/ suffix so :/etc/localtime resolves to the IANA zone it points at;
  • if intlResolveTimeZoneID still rejects the name, try ICU's primary-IANA canonical form;
  • otherwise parse POSIX std[+|-]hh and map it to the matching Etc/GMT zone (sign preserved, since both conventions are hours west of UTC).

An unrecognised value clears the override so the previous zone does not persist past a bad assignment. bun:jsc's setTimeZone() stays strict and still throws on non-IANA names.

The STD[+-]hh mapping runs on all platforms, so TZ=MSK-3 is honoured on Windows too; Node ignores TZ on Windows entirely, so this is intentionally more permissive there.

Known limitation

Node honours whole-hour POSIX offsets out to ±24h via a SimpleTimeZone, but Bun maps through Etc/GMT±h which only exists for +1..+12 / -1..-14. TZ=XXX+13 therefore still falls through. Extending the range would mean plumbing an offset-based TimeZone into JSC's DateCache, which is out of scope here.

Verification

test/js/node/process/process-env-tz.test.ts covers startup and runtime assignment for all of the above, including a symlink that stands in for a container-mounted /etc/localtime. 11 of the 20 cases fail on main and all pass with this change; the existing process.env.TZ and bun:jsc setTimeZone tests continue to pass.

…D[+-]hh')

JSC's DateCache resolves the default zone by name through
intlResolveTimeZoneID and collapses to UTC when that lookup fails, so
TZ values libc and Node honor (':/etc/localtime', 'MSK-3', 'UTC+5',
'/usr/share/zoneinfo/...') produced a 0 offset. Normalize them before
handing off to JSC: strip a leading ':', realpath absolute tzfile paths
and take the '/zoneinfo/' suffix, and map the POSIX 'std offset' form
to the matching Etc/GMT zone. An unrecognized value now clears the
override so a stale zone does not persist.

Fixes #8060
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 110fe852-52e5-4657-accb-31ec5519eb8b

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and e145bce.

📒 Files selected for processing (4)
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/jsc/bindings/JSEnvironmentVariableMap.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/process/process-env-tz.test.ts

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:41 AM PT - Jul 25th, 2026

⏳ @robobun, your commit e145bce is still building in Build #80691, but has 1 failures so far (All Failures):

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Support TZ=:/etc/localtime like Node.js #3649 - PR directly implements TZ=:/etc/localtime support by stripping the leading : and resolving symlink paths to IANA zone names

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #3649

🤖 Generated with Claude Code

Comment thread test/regression/issue/08060.test.ts Resolved
Comment thread test/regression/issue/08060.test.ts Resolved

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

No new findings this pass — the earlier feedback on test hermeticity and angle-bracket coverage has been addressed. Deferring to a human for the final call given this is ~100 lines of new C++ in the JSC bindings with a hand-rolled POSIX TZ parser, a realpath() filesystem probe, and a deliberate semantic change (unrecognized TZ now clears the prior override rather than leaving it in place).

What was reviewed:

  • Traced posixStdOffsetToEtcGMT against every accept/reject case in the test matrix; the <...> branch and notFound reject path are now both covered.
  • Confirmed bun:jsc's setTimeZone (BunJSCModule.h:629) is a separate path that still calls WTF::setTimeZoneOverride directly and throws — unchanged by this PR as the description claims.
  • Checked all Rust callers of JSGlobalObject__setTimeZone (run/test/repl commands) — all are startup-TZ-from-env, so the widened acceptance is intentional there.
  • Verified the reworked fall-through tests are now POSIX-gated with the OS TZ pinned to Etc/UTC, eliminating the host-zone dependency flagged earlier.
Extended reasoning...

Overview

The PR normalizes process.env.TZ values before handing them to JSC's WTF::setTimeZoneOverride, so POSIX conventions that ICU cannot canonicalize by name (leading :, absolute tzfile paths like :/etc/localtime, and STD[+|-]hh offset specs) resolve to a real IANA zone instead of collapsing to UTC. The change is ~100 lines of new C++ in JSEnvironmentVariableMap.cpp (setTimeZoneFromEnvValue + posixStdOffsetToEtcGMT), a one-line rewire in ZigGlobalObject.cpp so the startup path shares it, a header export, and a new 148-line test file.

Security risks

Low. The only new attack-surface-adjacent piece is realpath() on a user-controlled absolute path, but TZ is already user-controlled by definition, the call is read-only, the buffer is a stack char[PATH_MAX] (which is what realpath requires), and the result is fed through intlResolveTimeZoneID before being trusted. The parser operates on StringView with bounds-checked indexing throughout; I traced the digit loop and i - hstart > 2 cap and did not find an overflow path. No injection, auth, or data-exposure surface.

Level of scrutiny

This warrants a human look. It is not a mechanical change: there is a hand-rolled parser, an #if OS(UNIX) filesystem probe, and a deliberate behavioral change — an unrecognized TZ value now clears the WTF override (commit(emptyString())) where the old code left the previous override in place. That change is intentional per the PR description and covered by the last test, but it is the kind of user-visible semantic shift a maintainer should sign off on. The Etc/GMT±h mapping's asymmetric range (+1..+12 / -1..-14) and the decision to run the POSIX-offset mapping on Windows too (where Node ignores TZ entirely) are also design calls worth a human eye.

Other factors

All four items I raised in earlier passes are resolved in the current revision: the fall-through tests are now skipIf(!isPosix) with the child's OS TZ pinned to Etc/UTC and runtime assignment (so ICU's host-zone fallback is deterministic), the Windows registry-zone edge case is avoided by dropping the not.toBe(a) assertion in favor of expect(b).toBe(host) under the POSIX guard, and the angle-bracket branch has both accept (<+03>-3) and reject (<+03-3) coverage. The bug-hunting system found nothing new this run. I confirmed bun:jsc's setTimeZone still goes through its own strict path in BunJSCModule.h and is unaffected, and that the three Rust-side callers of JSGlobalObject__setTimeZone are all startup-TZ-from-env, matching the PR's intent. The old value.length() < 32 guard is dropped, which is fine — setTimeZoneOverride bounds-checks internally and long IANA names like America/Argentina/ComodRivadavia were previously being rejected by it.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the new test/js/node/process/process-env-tz.test.ts passes on every lane that built. The remaining red is build-step timeouts on linux x64 / aarch64-android / freebsd (infra; main build #80215 is failing the same way) plus two known darwin-aarch64 flakes (no-orphans.test.ts, 20144.test.ts) that this change does not touch. Ready for review.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Two more runtime-only rejections in the same family, observed on 1.4.0-canary (launch-time TZ= resolves all of them, so ICU's own $TZ parser strips these prefixes and this path should too):

process.env.TZ = "posix/Europe/Paris";   // kept previous zone
process.env.TZ = "right/Asia/Tokyo";     // kept previous zone
process.env.TZ = "europe/paris";         // kept previous zone

Reading the diff, extractZoneInfoSuffix strips posix/ and right/ only when the value is an absolute zoneinfo path. A bare posix/Europe/Paris goes straight to intlResolveTimeZoneID and fails. Case-insensitive europe/paris may already work through intlResolveTimeZoneID; I did not build this branch to check.

Unrelated to the above: #36045 drops the value.length() < 32 guard this PR also removes. Once either lands the other should rebase cleanly.

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #46698 replaces this PR. It keeps the two parts of this one that hold on the current WebKit: the :name form and a POSIX value of whole hours (MSK-3), for every writer of TZ. The tzfile paths (:/etc/localtime) need JavaScriptCore and are in oven-sh/WebKit#890.

@robobun robobun closed this Oct 24, 2026
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.

Support TZ=:/etc/localtime like Node.js Incorrect timezone when running within a Docker container.

2 participants