Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions crates/cli/tests/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
161 changes: 161 additions & 0 deletions crates/cli/tests/session_zone.rs
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 15 additions & 3 deletions crates/hook/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions site/pages/faq/en.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ <h3>Does it work with .NET, Java and Python applications?</h3>
<li><strong>.NET</strong> - the wall clock and the session time zone are covered.
<code>Stopwatch</code> stays on the real high-resolution counter unless you opt in
to scaling it.</li>
<li><strong>Java</strong> - the wall clock and elapsed time are covered. The session
time zone is <em>not</em> reached, which is a known gap.</li>
<li><strong>Java</strong> - the wall clock, elapsed time and the session time zone are
covered. The zone arrives as a fixed offset, named like <code>GMT+05:30</code>.</li>
<li><strong>Python</strong> - <code>time.time</code> and <code>datetime</code> are
covered. <code>perf_counter</code>, and <code>monotonic</code> on Python 3.13 and
later, sit on the high-resolution counter unless you opt in.</li>
Expand Down
4 changes: 2 additions & 2 deletions site/pages/faq/pl.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ <h3>Czy działa z aplikacjami .NET, Javy i Pythona?</h3>
<li><strong>.NET</strong> - zegar ścienny i strefa sesji są objęte.
<code>Stopwatch</code> zostaje na prawdziwym liczniku wysokiej rozdzielczości,
chyba że włączysz jego skalowanie.</li>
<li><strong>Java</strong> - zegar ścienny i czas trwania są objęte. Strefa sesji
<em>nie</em> jest osiągana i jest to znana dziura.</li>
<li><strong>Java</strong> - zegar ścienny, czas trwania i strefa sesji są objęte.
Strefa przychodzi jako stały offset, nazwany np. <code>GMT+05:30</code>.</li>
<li><strong>Python</strong> - <code>time.time</code> i <code>datetime</code> są objęte.
<code>perf_counter</code>, a od Pythona 3.13 również <code>monotonic</code>, siedzą
na liczniku wysokiej rozdzielczości, chyba że włączysz skalowanie.</li>
Expand Down
5 changes: 3 additions & 2 deletions site/pages/timezone-testing/en.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ <h2>Honest limits, and they matter here</h2>
registry knows. Anything mapping that name back to a zone - .NET's
<code>TimeZoneInfo.Local</code> among them - falls back to the offset instead, which
is the right answer. An application insisting on a registry name will not find one.</li>
<li><strong>Java does not pick the session zone up.</strong> The wall clock and elapsed
time are covered, the zone is not. It is a known gap, stated rather than hidden.</li>
<li><strong>Java picks the session zone up as an offset.</strong> The session zone has
no daylight saving time and no city behind it, so Java names it after its offset -
<code>GMT+05:30</code> for a session at +05:30.</li>
<li><strong>In Chromium and Electron mode the zone follows the host.</strong> The
instant is faked, the local-time getters are not.</li>
<li><strong>Offsets run from -14:00 to +14:00</strong> in whole minutes. Anything
Expand Down
5 changes: 3 additions & 2 deletions site/pages/timezone-testing/pl.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ <h2>Uczciwe ograniczenia - tu ważą najwięcej</h2>
nie zna żaden rejestr Windows. Cokolwiek mapuje tę nazwę z powrotem na strefę -
w tym <code>TimeZoneInfo.Local</code> w .NET - spada na offset, co jest właściwą
odpowiedzią. Aplikacja upierająca się przy nazwie z rejestru jej nie znajdzie.</li>
<li><strong>Java nie podchwytuje strefy sesji.</strong> Zegar ścienny i czas trwania są
objęte, strefa nie. To znana dziura, powiedziana wprost, a nie ukryta.</li>
<li><strong>Java przejmuje strefę sesji jako offset.</strong> Strefa sesji nie ma czasu
letniego ani miasta za sobą, więc Java nazywa ją od offsetu -
<code>GMT+05:30</code> dla sesji na +05:30.</li>
<li><strong>W trybie Chromium i Electron strefa idzie za hostem.</strong> Fałszowana
jest chwila, a nie funkcje czasu lokalnego.</li>
<li><strong>Offsety mieszczą się od -14:00 do +14:00</strong>, w pełnych minutach.
Expand Down
Loading