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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ Notable changes to Chrono Mock, newest first. The format follows

### Fixed

- **A batch script as the target lost its arguments, or never started.** Windows starts the command
interpreter for a `.bat` or `.cmd` itself and strips quotes from the line it hands over. So an
argument with a space, or a script in a folder with `&` in its name, kept the script from starting,
and the session said the application vanished right after injection. An empty argument disappeared
and moved the others up one place, and `a&b` gave the script `a` and ran `b` as a separate command.
The session now starts the interpreter from the system folder itself. Arguments with spaces, `&`,
`%` or nothing in them arrive as they were given, and `%OS%` stays those four characters, as it
does for a program, in an argument and in the script's own path. Two things arrive doubled, as the
interpreter cannot take them otherwise: a quote inside an argument, and the trailing backslash of
an argument that needs quotes. AutoRun commands from the registry still run, as they do when the
script is started any other way. An argument holding a line break, and a command line longer
than the interpreter's 8191 characters, are refused before anything starts, with exit 2, and
`--dry-run` refuses them too.
- **`--dry-run` approved sessions the real run refuses.** An impossible `--at` (month 13, 30 February,
25:61) was printed as the plan and exited 0, while the real run was refused by the core with
exit 1. A file Windows will not start (a text file, an empty `.exe`, one cut short inside its
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ column says which is which._
| 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 |
| Batch script (`.bat`, `.cmd`) as the target | experimental (command line) | measured on x64 by the test suite, on x86 by hand | Started through the command interpreter from the system folder, which is the process the session covers, together with everything the script starts. Arguments arrive as given, including spaces, `&`, an empty argument and `%` (`%OS%` stays those four characters). A quote inside an argument arrives doubled, and so does the trailing backslash of an argument that needs quotes, which keeps a script that passes `%*` on to a program correct. A command line longer than the interpreter's 8191 characters is refused before anything starts. The window takes an `.exe` only |
| Electron / Chromium | experimental (Chromium mode) | measured by hand on an Electron app (x64), not by the suite | A separate mechanism, not injection: the app is launched with a debug port and a clean isolated profile, and its own JS time APIs are put on the session clock over the DevTools protocol - reaching the sandboxed renderer and its Web Workers, where the timer often lives. The session zone follows the host zone (the instant is faked, not the local-time getters) |
| UWP / MSIX (Store apps) | not supported | declared (not exercised) | Packaging and launch model |
| ARM64 | out of scope through v1.0 | declared (not exercised) | To be revisited |
Expand Down
59 changes: 42 additions & 17 deletions crates/cli/src/run/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,25 +102,15 @@ fn inspect_target(target: &str, chromium: bool) -> TargetPath {
}
}

/// Whether `CreateProcessW` would start this file, which both mechanisms end in: a program's PE image,
/// or a batch script. A file that could not be read gets the benefit of the doubt - the plan does not
/// Whether the session can start this file: a program's PE image, or a batch script, which both
/// mechanisms start through the command interpreter (`chrono_mech::is_batch_script`, the same test
/// the launch makes). A file that could not be read gets the benefit of the doubt - the plan does not
/// know, so it does not refuse (untouchable rule 4).
///
/// A batch script it starts through the command interpreter itself, although Microsoft Learn says the
/// caller must start `cmd.exe /c` for it. Measured with a script that writes down what it received:
/// it runs and gets its arguments, spaces in its path or name included. Except when an argument
/// carries quotes - the interpreter Windows starts then strips the first quote and the last one on
/// the line, and cannot find the script. That is a fact about the launch, not about the file, so the
/// plan does not refuse it here.
///
/// Only WHETHER it is a program, never its bitness - see `mechanism_text` for why the header's
/// machine field is not trusted here.
fn windows_would_start(path: &Path) -> bool {
let batch = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("bat") || e.eq_ignore_ascii_case("cmd"));
batch || crate::pe::is_pe_image(path) != Some(false)
chrono_mech::is_batch_script(path) || crate::pe::is_pe_image(path) != Some(false)
}

/// The canonical path without the extended-length prefix Windows answers `canonicalize` with. That
Expand Down Expand Up @@ -156,9 +146,9 @@ struct Plan<'a> {
zone_bias_min: i32,
}

/// Print the plan and start nothing. Exit 0, except for a path that definitely leads to no file or
/// to a file Windows will not start, which exits 2 - the code a real run gives for the same fact
/// (docs/08 section 8).
/// Print the plan and start nothing. Exit 0, except for a path that definitely leads to no file, to a
/// file Windows will not start, or to a batch script its launch would refuse, which exits 2 - the code
/// a real run gives for the same fact (docs/08 section 8).
pub(super) fn dry_run(ra: &RunArgs, spec: &TimeSpec, origin: &TimeOrigin, now_bias: i32) -> i32 {
// The same pure function the core calls, so the plan names the mechanism the core would choose
// and not one worked out a second way (ADR-9).
Expand Down Expand Up @@ -195,6 +185,15 @@ pub(super) fn dry_run(ra: &RunArgs, spec: &TimeSpec, origin: &TimeOrigin, now_bi
);
return 2;
}
// The launch refuses some batch launches (a line break in an argument, a line longer than the
// interpreter runs), so the plan does too, through the same checks and with the same code.
if matches!(plan.target, TargetPath::Found(_))
&& chrono_mech::is_batch_script(Path::new(&ra.target))
&& let Some(problem) = chrono_mech::batch_launch_problem(&ra.target, &ra.args)
{
eprintln!("chrono: {problem}, so a real run would refuse to start it (exit 2)");
return 2;
}
0
}

Expand Down Expand Up @@ -253,6 +252,14 @@ fn mechanism_text(p: &Plan) -> String {
if p.chromium {
return "Chromium or Electron over CDP - the folder carries the Chromium runtime".to_string();
}
// The process started is the command interpreter from the system folder, whose bitness is this
// core's, and what the audit then reports is that interpreter and whatever the script starts.
if chrono_mech::is_batch_script(Path::new(&p.ra.target)) {
return format!(
"native injection into the command interpreter that runs the script, from the {} core",
this_bitness()
);
}
// The bitness named here is THIS executable's, never the target's. The target's is read from the
// running process by chrono-mech, because a .NET AnyCPU image carries IMAGE_FILE_MACHINE_I386 in
// its header and runs 64-bit anyway - a plan that read the header would refuse targets that work.
Expand Down Expand Up @@ -688,4 +695,22 @@ mod tests {
assert!(mechanism_text(plan).contains("not decided"), "{}", mechanism_text(plan));
});
}

/// A batch script is started through the command interpreter, so that is what the plan says the
/// hook goes into - and not that a 32-bit script needs the other build, which means nothing for
/// a script.
#[test]
fn a_batch_script_is_planned_as_the_command_interpreter_that_runs_it() {
let dir = crate::testutil::unique_temp_dir("chrono-plan-batch");
std::fs::create_dir_all(&dir).expect("scratch dir");
let script = dir.join("run.cmd");
std::fs::write(&script, "@echo off\r\n").expect("batch file");
plan_for(&[&script.display().to_string(), "--at", "2030-01-01T00:00:00"], |plan| {
let text = mechanism_text(plan);
assert!(text.contains("command interpreter that runs the script"), "{text}");
assert!(text.contains(this_bitness()), "{text}");
assert!(!text.contains("32-bit target"), "{text}");
});
let _ = std::fs::remove_dir_all(&dir);
}
}
112 changes: 112 additions & 0 deletions crates/cli/tests/batch_script.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! A batch script as the target, run for real: its arguments reach it as they were given, from a
//! folder whose name holds a space, an ampersand, brackets and a literal `%OS%`.
//!
//! Found 2026-09-25 by a script that writes down what it received. `CreateProcessW` starts the
//! command interpreter for a batch file itself, with no quotes around the line, and the interpreter
//! then strips the first quote and the last one. An argument with a space, or a folder with `&` in
//! its name, kept the script from starting while the session reported a target that vanished, an
//! empty argument vanished and shifted the rest, and `a&b` ran its second half as a separate
//! command. The launch now starts the interpreter itself (`chrono_mech`, `batch.rs`, ADR-15).
//!
//! The evidence is the file the script writes, not the exit code: a script that ends at once can
//! end inside the opening guard window, which is its own verdict (ADR-4).

use std::path::PathBuf;
use std::process::Command;
use std::sync::{Mutex, MutexGuard};

/// Held by every test here that starts the core. The core allows one session at a time, and the
/// tests of one file run on parallel threads.
static REAL_SESSION: Mutex<()> = Mutex::new(());

fn one_real_session_at_a_time() -> MutexGuard<'static, ()> {
REAL_SESSION.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// The library the session injects, beside the binary under test. `cargo test` does not build it
/// (see `dry_run.rs`), so its absence is named rather than read as a failure of the launch.
fn require_injected_library() {
let library = PathBuf::from(env!("CARGO_BIN_EXE_chrono"))
.parent()
.expect("the binary under test lives in a directory")
.join("chrono_hook.dll");
assert!(
library.is_file(),
"this 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()
);
}

/// A folder named the way that broke the launch, with a script in it that writes what it got. Six
/// arguments, each read with `%~N`, which drops the quotes the launch put around it.
fn script_folder(name: &str) -> (PathBuf, PathBuf) {
// `%OS%` is part of the folder's name, not a variable: the interpreter expands the whole line, the
// script's path included, and a script in such a folder was not found (measured).
let dir = std::env::temp_dir().join(format!("chrono batch {name} R&D (x86) %OS% {}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch directory");
let script = dir.join("probe script.bat");
let body = [
"@echo off",
"setlocal EnableDelayedExpansion",
"set \"a1=%~1\"",
"set \"a2=%~2\"",
"set \"a3=%~3\"",
"set \"a4=%~4\"",
"set \"a5=%~5\"",
"set \"a6=%~6\"",
"> \"%~dp0args.txt\" echo [!a1!][!a2!][!a3!][!a4!][!a5!][!a6!]",
"",
]
.join("\r\n");
std::fs::write(&script, body).expect("the script");
(dir, script)
}

#[test]
fn a_batch_script_gets_its_arguments_as_they_were_given() {
require_injected_library();
let _session = one_real_session_at_a_time();
let (dir, script) = script_folder("args");
let out = Command::new(env!("CARGO_BIN_EXE_chrono"))
.args([
"run",
&script.display().to_string(),
"--args",
r#""one a" a&b 50% %OS% "" last"#,
"--at",
"2038-01-19T03:14:07",
])
.output()
.expect("the tool must run");
let said = format!("stdout: {} stderr: {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let got = std::fs::read_to_string(dir.join("args.txt"))
.unwrap_or_else(|e| panic!("the script never ran ({e}), so it wrote nothing. {said}"));
// A space, an ampersand, a percent sign, a variable's name, an empty argument and one after it,
// every one where it was given. `%OS%` stays those four characters, as it does for a program.
assert_eq!(got.trim_end(), "[one a][a&b][50%][%OS%][][last]", "{said}");
let _ = std::fs::remove_dir_all(&dir);
}

/// Two launches the interpreter would not run as given: an argument with a line break, which cuts
/// its line short, and a line longer than the 8 191 units it takes, which it answers with "The
/// command line is too long." Both used to end as a target that vanished. Both are refused before
/// anything starts, with the code of a target that could not be started, and the script never runs.
#[test]
fn a_launch_the_interpreter_would_not_run_is_refused_before_anything_starts() {
require_injected_library();
let _session = one_real_session_at_a_time();
let (dir, script) = script_folder("refused");
for (args, why) in [("\"a\nb\"".to_string(), "line break"), ("a".repeat(8200), "8191")] {
let out = Command::new(env!("CARGO_BIN_EXE_chrono"))
.args(["run", &script.display().to_string(), "--args", &args, "--at", "2038-01-19T03:14:07"])
.output()
.expect("the tool must run");
let said = format!("stdout: {} stderr: {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert_eq!(out.status.code(), Some(2), "{why}: {said}");
assert!(said.contains(why), "the refusal must say why: {said}");
assert!(!dir.join("args.txt").exists(), "the script must not have run: {said}");
}
let _ = std::fs::remove_dir_all(&dir);
}
13 changes: 13 additions & 0 deletions crates/cli/tests/dry_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,19 @@ fn a_plan_refuses_what_the_run_would_refuse_and_nothing_else() {
.expect("the tool must run");
assert_eq!(out.status.code(), Some(0), "a batch script is started by Windows: {}", String::from_utf8_lossy(&out.stderr));

// The same script given a launch the interpreter would not run (a line break that cuts its line
// short, a line longer than it takes): the plan refuses both too, with the code the run gives
// (batch_script.rs).
for (args, why) in [("\"a\nb\"".to_string(), "line break"), ("a".repeat(8200), "8191")] {
let out = Command::new(env!("CARGO_BIN_EXE_chrono"))
.args(["run", &script.display().to_string(), "--args", &args, "--at", "2038-01-19T03:14:07", "--dry-run"])
.output()
.expect("the tool must run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{why}: {stderr}");
assert!(stderr.contains(why), "{why}: {stderr}");
}

// A library is a whole PE image, and still not a program: its header says so, and Windows
// refuses it. The state, not only the code, because a missing file exits 2 as well.
let library = injected_library();
Expand Down
7 changes: 7 additions & 0 deletions crates/cli/tests/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ const ALLOWED: &[(&str, &str, &str)] = &[
launch a file Windows will not start - the real runs are what prove the plans can fail. \
None reaches past this machine",
),
(
"crates/cli/tests/batch_script.rs",
"spawn",
"runs the built binary on a batch script in a scratch folder, to see the arguments reach the \
script as they were given and a launch the interpreter would not run refused before anything \
starts. The script only writes a file beside itself, and nothing reaches past this machine",
),
(
"crates/cli/tests/usage.rs",
"spawn",
Expand Down
Loading
Loading