From 6c4ad8136282ec5c7b48203eddecf50674302efd Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 17:33:21 +0200 Subject: [PATCH] fix(hook): say the session zone has no DST so the JVM builds it from the offset The dynamic zone the hook hands out left DynamicDaylightTimeDisabled at FALSE. The JVM (TimeZone_md.c, every line from 8 on) then looks the key name "Chrono Session" up in its own mapping table, misses, and reads ActiveTimeBias from the real registry, so every Java application under a session showed the machine's zone while the verdict said works. MS Learn describes a zone without daylight saving time as this field set with both transition dates cleared, which is exactly the session zone. With it set the JVM builds the zone from Bias (GMT+05:30), and ICU names a whole-hour offset Etc/GMT-N instead of leaving it unnamed. .NET, the C runtime and Go do not read the field, checked in their sources and by the harness. Guard: crates/cli/tests/session_zone.rs reads the field through kernel32 from Windows PowerShell under a session, with a control run without one. With the field reverted it fails on that assertion and on no other. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ README.md | 10 +- crates/cli/tests/network.rs | 8 ++ crates/cli/tests/session_zone.rs | 161 ++++++++++++++++++++++++++++ crates/hook/src/lib.rs | 18 +++- site/pages/faq/en.html | 4 +- site/pages/faq/pl.html | 4 +- site/pages/timezone-testing/en.html | 5 +- site/pages/timezone-testing/pl.html | 5 +- 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 crates/cli/tests/session_zone.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4e9e5..f4cdd60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ Notable changes to Chrono Mock, newest first. The format follows ### Fixed +- **Java applications kept the machine's time zone.** A Java application under a session read the + session date but showed it in the machine's own time zone, on every Java version from 8 on and on + both 32 and 64 bit, while the session reported success. The zone the session hands out did not say + that it has no daylight saving time, so Java took it for a named zone, looked the name up, found + nothing, and fell back to the machine's current offset from the registry. The zone now says so, + and Java builds it from the session offset, under a name like `GMT+05:30`. Node.js and Deno name a + whole-hour session zone the same way now (`Etc/GMT-5`, and `UTC` for +00:00), where until now they + gave it no name at all. The offset they use is unchanged. - **The network caution missed most applications that go online.** The audit is meant to say when an application opened a network connection, because it may then take the time from a server, which no local substitution reaches. It watched one Windows function for that, and two of the diff --git a/README.md b/README.md index 70f9d79..90672bc 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,11 @@ cover something is worse than one that says what it cannot do. boundary drifts an hour from what that zone would really show. Forcing an application through a DST transition is therefore not something this tool does yet, and the date calculator says so rather than guessing -- **The zone reports itself as "Chrono Session"**, a name no Windows registry knows. Anything that - maps that name back to a zone (.NET's `TimeZoneInfo.Local` among them) falls back to the offset - instead, which is the right answer - but a target that insists on a registry name will not find one +- **The zone reports itself as "Chrono Session"**, a name no Windows registry knows, and says it + has no daylight saving time. Anything that maps that name back to a zone falls back to the offset + instead, which is the right answer: .NET's `TimeZoneInfo.Local` keeps the name, Java names the zone + after its offset (`GMT+05:30`), and so do Node.js and Deno for a whole hour (`Etc/GMT-5`). A target + that insists on a registry name will not find one - **Chrono Mock cleans up after itself. It cannot clean up after the application you tested.** See the warning below - Windows only. No macOS, no Linux - `libfaketime` already covers Linux well @@ -356,7 +358,7 @@ column says which is which._ |---|---|---|---| | Native Win32 / Win64 (C, C++, Delphi) | supported | measured on x64, x86 | The cleanest case | | .NET (Framework and modern) | experimental | measured on x64, x86 | Time calls go through Win32 exports and are covered, including the session time zone. Stopwatch stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`), which accelerates it too | -| Java (JVM) | experimental | measured on x64, x86 | Wall clock and elapsed time are covered. The session time zone is not reached - a known gap. nanoTime stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`) | +| Java (JVM) | experimental | measured on x64, x86 | Wall clock, elapsed time and the session time zone are covered. The zone arrives as a fixed offset named after it, like `GMT+05:30`. nanoTime stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`) | | Python (CPython, incl. PyInstaller) | experimental | measured by hand on x64, not by the suite | Wall clock (time.time, datetime) and the session time zone (time.localtime) are covered. perf_counter, and monotonic on Python 3.13+, are on the high-resolution counter - real by default, accelerated when you opt in with Scale QPC (`--scale-qpc`) | | Applications reading time from the network | out of scope by definition | measured on x64, x86 | The audit detects it - every connection attempt made through Windows' own socket layer is observed and warned about, whichever function made it (Winsock, WinHTTP, WinINet, and the .NET, Node.js, Go, Java and Python runtimes). A datagram sent without a connection is not a connection and is not counted. A third-party Winsock provider, rare on current Windows, is not watched | | Embedded web engine inside a native app (WebView2, Qt WebEngine) | experimental | measured by hand on a WebView2 host and a Qt WebEngine host (x64), not by the suite | The native hook covers the application and the pages inside it are reached over the engine's debugging port, opened for the session through two environment variables the application inherits. The pages read the session clock at the session rate and follow a speed change and a jump. The engine's helper processes and the renderer's native reads stay on the real clock, so the verdict is PARTIAL and says why. The pages keep the machine's time zone. An elevated application is out of reach - the engine ignores the variables | diff --git a/crates/cli/tests/network.rs b/crates/cli/tests/network.rs index 8502deb..f3203ac 100644 --- a/crates/cli/tests/network.rs +++ b/crates/cli/tests/network.rs @@ -169,6 +169,14 @@ const ALLOWED: &[(&str, &str, &str)] = &[ through the built binary, and reads the date it wrote to a scratch file. Neither reaches \ past this machine", ), + ( + "crates/cli/tests/session_zone.rs", + "spawn", + "runs Windows PowerShell twice, once alone as the control and once under a session through \ + the built binary, with a probe script that compiles its one declaration using the C# \ + compiler the .NET Framework ships with and writes the zone it read to a scratch file. \ + Neither reaches past this machine", + ), ( "crates/cli/tests/duration_axis.rs", "spawn", diff --git a/crates/cli/tests/session_zone.rs b/crates/cli/tests/session_zone.rs new file mode 100644 index 0000000..7138e87 --- /dev/null +++ b/crates/cli/tests/session_zone.rs @@ -0,0 +1,161 @@ +//! A session's zone has to say it has no daylight saving time, or the JVM never sees it. +//! +//! The session zone is a fixed offset. The hook hands it out through `GetDynamicTimeZoneInformation` +//! under the key name "Chrono Session", which no registry knows. What a runtime does with a name it +//! cannot look up depends on one field of that answer, `DynamicDaylightTimeDisabled`. When it is set, +//! the JVM (every line from 8 to the current one, `TimeZone_md.c`) builds its zone from the `Bias` the +//! hook returned. When it is clear, the JVM looks the name up in its own mapping table, misses, and +//! reads `ActiveTimeBias` from the REAL registry instead. Until 2026-09-23 it was clear, so every Java +//! application under a session showed the host's zone while the audit reported the session as working. +//! +//! The JVM itself is measured by the harness, which CI does not have. What CI can check on a clean +//! runner is the field, read the way the JVM reads it: Windows PowerShell is part of every Windows +//! installation and can call the function through kernel32 with nothing outside the repository. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The session zone, a half-hour offset, so the bias it produces cannot be the host's by coincidence +/// on any runner this test is expected to meet. +const SESSION_ZONE: &str = "+05:30"; + +/// The bias that zone has to arrive as, in the Win32 sense (UTC = local + bias). +const SESSION_BIAS: &str = "-330"; + +/// The key name only the hook hands out, so seeing it proves the session reached the probe. +const SESSION_KEY: &str = "Chrono Session"; + +/// The probe: one call to `GetDynamicTimeZoneInformation` through kernel32, written to a file, +/// because the output of a target running under a session does not reach the caller's pipe. +const PROBE: &str = r#"Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class ZoneProbe { + [StructLayout(LayoutKind.Sequential)] + public struct SystemTime { public ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct DynamicZone { + public int Bias; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string StandardName; + public SystemTime StandardDate; + public int StandardBias; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DaylightName; + public SystemTime DaylightDate; + public int DaylightBias; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string TimeZoneKeyName; + [MarshalAs(UnmanagedType.U1)] public bool DynamicDaylightTimeDisabled; + } + [DllImport("kernel32.dll")] + public static extern uint GetDynamicTimeZoneInformation(out DynamicZone zone); +} +'@ +$z = New-Object ZoneProbe+DynamicZone +$null = [ZoneProbe]::GetDynamicTimeZoneInformation([ref]$z) +Set-Content -Path zone.txt -Value ("key={0}|bias={1}|dstoff={2}" -f $z.TimeZoneKeyName, $z.Bias, $z.DynamicDaylightTimeDisabled) +"#; + +/// Windows PowerShell, by full path, for the same reason `dry_run.rs` gives for the interpreter. +fn windows_powershell() -> String { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); + format!(r"{root}\System32\WindowsPowerShell\v1.0\powershell.exe") +} + +/// The arguments that run the probe from the current directory. +const PROBE_ARGS: &str = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File zone.ps1"; + +/// The injected library, which `cargo test` does not build (`dry_run.rs` has the whole story). +fn injected_library() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_chrono")) + .parent() + .expect("the binary under test lives in a directory") + .join("chrono_hook.dll") +} + +/// A scratch directory for one run of this test holding the probe, removed afterwards. +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("chrono-session-zone-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + std::fs::write(dir.join("zone.ps1"), PROBE).expect("the probe script"); + dir +} + +/// The line the probe wrote in `dir`, or an empty string when it wrote nothing. +fn written_zone(dir: &Path) -> String { + std::fs::read_to_string(dir.join("zone.txt")).unwrap_or_default().trim().to_string() +} + +/// One `name=value` field of the probe's line, or `None` when the line does not carry it. +fn field<'a>(line: &'a str, name: &str) -> Option<&'a str> { + line.split('|').find_map(|part| part.strip_prefix(name)?.strip_prefix('=')) +} + +/// The zone the hook hands out through `GetDynamicTimeZoneInformation` carries the session's key and +/// bias, and says daylight saving time is disabled, which is what makes the JVM build its zone from +/// that bias instead of from the real registry. +#[test] +fn the_dynamic_zone_of_a_session_has_daylight_saving_time_disabled() { + let library = injected_library(); + assert!( + library.is_file(), + "this probe drives a real session and needs {}, which `cargo test` does not build. \ + Run `cargo build --workspace` first - CI and tools/gates.ps1 both do that.", + library.display() + ); + + // The control first: without a session the probe has to write a well-formed line that does NOT + // carry the session key. Without it a probe that never ran, or one that always wrote the key, + // would let the assertions below pass or fail for reasons unrelated to the hook. + let control = scratch("control"); + let status = Command::new(windows_powershell()) + .args(PROBE_ARGS.split(' ')) + .current_dir(&control) + .status() + .expect("Windows PowerShell must run"); + assert!(status.success(), "the probe could not run without a session"); + let real = written_zone(&control); + assert!( + field(&real, "key").is_some() && field(&real, "bias").is_some() && field(&real, "dstoff").is_some(), + "the probe wrote {real:?} without a session, so it cannot read the zone at all" + ); + assert_ne!(field(&real, "key"), Some(SESSION_KEY), "the host already reports the session key: {real}"); + let _ = std::fs::remove_dir_all(&control); + + let dir = scratch("session"); + let out = Command::new(env!("CARGO_BIN_EXE_chrono")) + .args([ + "run", + &windows_powershell(), + "--args", + PROBE_ARGS, + "--cwd", + &dir.display().to_string(), + "--at", + "2091-06-15T12:00:00", + "--zone", + SESSION_ZONE, + ]) + .output() + .expect("the tool must run"); + let seen = written_zone(&dir); + let context = format!( + "The probe wrote {seen:?} under the session and {real:?} without it. stdout: {} stderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + assert_eq!(field(&seen, "key"), Some(SESSION_KEY), "the session never reached the probe. {context}"); + assert_eq!( + field(&seen, "bias"), + Some(SESSION_BIAS), + "the session zone {SESSION_ZONE} arrived with the wrong bias. {context}" + ); + assert_eq!( + field(&seen, "dstoff"), + Some("True"), + "the session zone does not say daylight saving time is disabled, so the JVM looks its key up, \ + misses, and takes the host's zone from the real registry. {context}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/hook/src/lib.rs b/crates/hook/src/lib.rs index 3924d01..c5b06ee 100644 --- a/crates/hook/src/lib.rs +++ b/crates/hook/src/lib.rs @@ -908,8 +908,8 @@ unsafe extern "system" fn h_ntqsi(class: i32, info: *mut c_void, len: u32, retle }} // --- Session zone ------------------------------------------------------------- -// Report the session zone (Bias = tz_bias, no DST) so a target's notion of "which -// zone am I in" agrees with the shifted GetLocalTime. +// Report the session zone (Bias = tz_bias, no DST, and in the dynamic form DST explicitly +// disabled) so a target's notion of "which zone am I in" agrees with the shifted GetLocalTime. const SESSION_ZONE_NAME: &str = "Chrono Session"; const TIME_ZONE_ID_INVALID: u32 = 0xFFFF_FFFF; @@ -937,7 +937,19 @@ unsafe extern "system" fn h_gdtzi(lp: *mut DYNAMIC_TIME_ZONE_INFORMATION) -> u32 return O_GDTZI.get().map(|o| o(lp)).unwrap_or(TIME_ZONE_ID_INVALID); } if !lp.is_null() { - let mut d = DYNAMIC_TIME_ZONE_INFORMATION { Bias: cur_tz_bias(), ..Default::default() }; + // DynamicDaylightTimeDisabled is TRUE because the session zone has no daylight saving time, and + // TRUE with both transition dates cleared is how MS Learn says a zone without it is described. + // FALSE would claim dynamic transition data exists under a registry key that does not. It is + // also the field a runtime reads to decide whether the zone can be built from Bias alone: the + // JVM (every line from 8 to the current one) does exactly that when it is TRUE, and otherwise + // looks the key name up in its own table, misses, and falls back to ActiveTimeBias from the + // REAL registry, which is how Java kept the host zone under every session. ICU names a + // whole-hour offset Etc/GMT-N on the same flag. .NET, the C runtime and Go do not read it. + let mut d = DYNAMIC_TIME_ZONE_INFORMATION { + Bias: cur_tz_bias(), + DynamicDaylightTimeDisabled: true, + ..Default::default() + }; set_wide(&mut d.StandardName, SESSION_ZONE_NAME); set_wide(&mut d.TimeZoneKeyName, SESSION_ZONE_NAME); *lp = d; diff --git a/site/pages/faq/en.html b/site/pages/faq/en.html index 82fef38..8ec97d6 100644 --- a/site/pages/faq/en.html +++ b/site/pages/faq/en.html @@ -58,8 +58,8 @@

Does it work with .NET, Java and Python applications?

  • .NET - the wall clock and the session time zone are covered. Stopwatch stays on the real high-resolution counter unless you opt in to scaling it.
  • -
  • Java - the wall clock and elapsed time are covered. The session - time zone is not reached, which is a known gap.
  • +
  • Java - the wall clock, elapsed time and the session time zone are + covered. The zone arrives as a fixed offset, named like GMT+05:30.
  • Python - time.time and datetime are covered. perf_counter, and monotonic on Python 3.13 and later, sit on the high-resolution counter unless you opt in.
  • diff --git a/site/pages/faq/pl.html b/site/pages/faq/pl.html index 2ada0b4..562c331 100644 --- a/site/pages/faq/pl.html +++ b/site/pages/faq/pl.html @@ -57,8 +57,8 @@

    Czy działa z aplikacjami .NET, Javy i Pythona?

  • .NET - zegar ścienny i strefa sesji są objęte. Stopwatch zostaje na prawdziwym liczniku wysokiej rozdzielczości, chyba że włączysz jego skalowanie.
  • -
  • Java - zegar ścienny i czas trwania są objęte. Strefa sesji - nie jest osiągana i jest to znana dziura.
  • +
  • Java - zegar ścienny, czas trwania i strefa sesji są objęte. + Strefa przychodzi jako stały offset, nazwany np. GMT+05:30.
  • Python - time.time i datetime są objęte. perf_counter, a od Pythona 3.13 również monotonic, siedzą na liczniku wysokiej rozdzielczości, chyba że włączysz skalowanie.
  • diff --git a/site/pages/timezone-testing/en.html b/site/pages/timezone-testing/en.html index bc64b5d..0c3c185 100644 --- a/site/pages/timezone-testing/en.html +++ b/site/pages/timezone-testing/en.html @@ -95,8 +95,9 @@

    Honest limits, and they matter here

    registry knows. Anything mapping that name back to a zone - .NET's TimeZoneInfo.Local among them - falls back to the offset instead, which is the right answer. An application insisting on a registry name will not find one. -
  • Java does not pick the session zone up. The wall clock and elapsed - time are covered, the zone is not. It is a known gap, stated rather than hidden.
  • +
  • Java picks the session zone up as an offset. The session zone has + no daylight saving time and no city behind it, so Java names it after its offset - + GMT+05:30 for a session at +05:30.
  • In Chromium and Electron mode the zone follows the host. The instant is faked, the local-time getters are not.
  • Offsets run from -14:00 to +14:00 in whole minutes. Anything diff --git a/site/pages/timezone-testing/pl.html b/site/pages/timezone-testing/pl.html index 0b626e3..1b65ed1 100644 --- a/site/pages/timezone-testing/pl.html +++ b/site/pages/timezone-testing/pl.html @@ -91,8 +91,9 @@

    Uczciwe ograniczenia - tu ważą najwięcej

    nie zna żaden rejestr Windows. Cokolwiek mapuje tę nazwę z powrotem na strefę - w tym TimeZoneInfo.Local w .NET - spada na offset, co jest właściwą odpowiedzią. Aplikacja upierająca się przy nazwie z rejestru jej nie znajdzie.
  • -
  • Java nie podchwytuje strefy sesji. Zegar ścienny i czas trwania są - objęte, strefa nie. To znana dziura, powiedziana wprost, a nie ukryta.
  • +
  • Java przejmuje strefę sesji jako offset. Strefa sesji nie ma czasu + letniego ani miasta za sobą, więc Java nazywa ją od offsetu - + GMT+05:30 dla sesji na +05:30.
  • W trybie Chromium i Electron strefa idzie za hostem. Fałszowana jest chwila, a nie funkcje czasu lokalnego.
  • Offsety mieszczą się od -14:00 do +14:00, w pełnych minutach.