refactor: harden IPC, hook install, and injection safety

Workspace-wide refactor that fixes correctness, safety, and concurrency
issues across the three crates while preserving the public CLI/UX surface.

core:
- Split SharedDelta into SharedDeltaReader / SharedDeltaWriter so the
  access-mode (FILE_MAP_READ vs ALL_ACCESS) is encoded in the type.
- Use AtomicI64::from_ptr on the page-aligned mapped view instead of a
  raw *mut AtomicI64 cast.
- Detect ERROR_ALREADY_EXISTS via CreateOutcome and surface it.
- Move MMF name to the Global\ namespace so cross-session injection is
  no longer silently scoped to the controller's session.
- Add tick-math round-trip tests (i64 + SYSTEMTIME boundary cases).

hook:
- install_hook! macro collapses the five hook installs to a table;
  per-hook failures are collected into InstallReport instead of
  aborting mid-chain and leaving a partial state armed.
- Unify fake_filetime helpers behind a single trampoline-parameterised fn.
- Fix GetLocalTime: previously returned UTC; now goes
  FILETIME(UTC) -> SYSTEMTIME -> SystemTimeToTzSpecificLocalTime so DST
  is resolved against the source date, matching real GetLocalTime.
- Replace thread::spawn from DllMain with raw CreateThread and
  DisableThreadLibraryCalls(hinst) to avoid loader-lock deadlocks;
  close the returned thread handle to plug a per-injection kernel leak.
- Propagate the real NtQuerySystemTime NTSTATUS instead of always
  returning STATUS_SUCCESS.
- Return STATUS_ACCESS_VIOLATION on null out-pointer.
- Pipe InstallReport + MMF-open failures to OutputDebugStringW for
  DbgView visibility in the target process.

ui:
- New win32_process_info module: pe_machine reads up to 64 KiB so PEs
  with large e_lfanew values parse cleanly; query_full_image_name and
  is_native_x64 (IsWow64Process2) gate inject against PID reuse and
  WoW64 / non-AMD64 targets.
- Drop for InjectionManager zeroes every injected process's delta so
  targets return to real time on UI exit.
- inject() reorders checks so the system-process guard runs against the
  filename derived from the live image path, not the stale watcher
  snapshot; failures are pushed to the ring-buffer log so auto-inject
  loops are no longer silent.
- Refuse to inject critical Windows processes (csrss, smss, lsass,
  services, svchost, MsMpEng, ...) explicitly.
- Switch the log from unbounded Vec to a VecDeque ring buffer (cap 1000).
- Rename eject -> disable to match what it actually does (zero delta,
  keep DLL loaded).
- Unicode-aware paths_equivalent via to_lowercase comparison so non-ASCII
  case differences don't yield false-positive PID-reuse errors.
- app.rs date/time picker: switch to DragValue so mid-typing keystrokes
  don't rewrite the date; clamp year 1970-2200; checked arithmetic on
  unix_micros -> FILETIME so far-future inputs don't silently overflow;
  surface DST-gap status to the user; "Now" auto-applies.
- Add 51 unit tests across mmf, PE parser, system-process guards, time
  math; expose helpers via pub(crate) for test access.
- Drop unused serde_json and thiserror deps.

Total: 56/56 tests passing, cargo clippy clean, cargo build --release
produces time_mocker_ui.exe and time_mocker_hook.dll.
This commit is contained in:
2026-05-20 17:08:43 +07:00
parent b34dd391e4
commit 10f73fc32c
12 changed files with 1199 additions and 235 deletions
Generated
+21 -28
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "ab_glyph"
@@ -710,6 +710,12 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1129,12 +1135,6 @@ dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
@@ -2158,19 +2158,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -2390,6 +2377,19 @@ dependencies = [
"windows 0.61.3",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2460,9 +2460,8 @@ dependencies = [
"globset",
"regex",
"serde",
"serde_json",
"sysinfo 0.32.1",
"thiserror 2.0.18",
"tempfile",
"time-mocker-core",
"windows-sys 0.59.0",
]
@@ -3463,9 +3462,3 @@ dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+9 -5
View File
@@ -1,8 +1,12 @@
//! Shared types and named MMF helper for time-mocker.
//!
//! IPC contract: an 8-byte memory-mapped file named `TimeMocker_<pid>` holds an
//! i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to the real
//! system FILETIME by the injected hook.
//! IPC contract: an 8-byte memory-mapped file named `Global\TimeMocker_<pid>`
//! holds an i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to
//! the real system FILETIME by the injected hook.
//!
//! The `Global\` namespace lets the admin controller in session 1 reach
//! processes in session 0 (services) and other sessions. Single-user same-
//! session injection works too — `Global\` is a superset of `Local\`.
#![cfg(windows)]
@@ -10,10 +14,10 @@ pub mod mmf;
pub mod ticks;
pub mod types;
pub use mmf::SharedDelta;
pub use mmf::{CreateOutcome, SharedDeltaReader, SharedDeltaWriter};
pub use types::MockTimeInfo;
pub const MMF_PREFIX: &str = "TimeMocker_";
pub const MMF_PREFIX: &str = "Global\\TimeMocker_";
#[inline]
pub fn mmf_name_for_pid(pid: u32) -> String {
+239 -69
View File
@@ -1,8 +1,11 @@
//! Named memory-mapped file wrapper around the 8-byte `MockTimeInfo` payload.
//!
//! Both the controller (writer) and the injected hook DLL (reader) attach to
//! the same `TimeMocker_<pid>` mapping. We use raw `windows-sys` because
//! `memmap2` doesn't expose named pagefile-backed mappings on Windows.
//! Two distinct types model the access split: `SharedDeltaWriter` (controller
//! side, FILE_MAP_ALL_ACCESS) and `SharedDeltaReader` (injected hook DLL,
//! FILE_MAP_READ). Both alias the same kernel object via its name.
//!
//! Raw `windows-sys` is used because `memmap2` doesn't expose named
//! pagefile-backed mappings on Windows.
use std::ffi::OsStr;
use std::io;
@@ -10,7 +13,9 @@ use std::os::windows::ffi::OsStrExt;
use std::ptr;
use std::sync::atomic::{AtomicI64, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
@@ -18,81 +23,32 @@ use windows_sys::Win32::System::Memory::{
use crate::types::MockTimeInfo;
/// Read/write handle to the shared delta.
pub struct SharedDelta {
handle: HANDLE,
view: *mut AtomicI64,
#[allow(dead_code)]
name: String,
}
unsafe impl Send for SharedDelta {}
unsafe impl Sync for SharedDelta {}
fn wide(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
impl SharedDelta {
/// Create (or open) the named mapping. Used by the controller side.
pub fn create(name: &str) -> io::Result<Self> {
let wname = wide(name);
let handle = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
ptr::null(),
PAGE_READWRITE,
0,
MockTimeInfo::SIZE as u32,
wname.as_ptr(),
)
};
if handle.is_null() {
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
}
Self::map_view(handle, name, FILE_MAP_ALL_ACCESS)
}
/// Shared bookkeeping for an open mapping. Owns the handle + view and frees
/// both on Drop. `view` is page-aligned (`MapViewOfFile` guarantee) so the
/// underlying i64 is 8-byte aligned and tear-free under `AtomicI64::from_ptr`.
struct MappingHandle {
handle: HANDLE,
view: *mut i64,
}
/// Open an existing mapping. Used by the injected hook DLL.
pub fn open(name: &str) -> io::Result<Self> {
let wname = wide(name);
let handle = unsafe { OpenFileMappingW(FILE_MAP_READ, 0, wname.as_ptr()) };
if handle.is_null() {
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
}
Self::map_view(handle, name, FILE_MAP_READ)
}
// Safety: the handle/view are stable for the lifetime of `MappingHandle` and
// the i64 is accessed only through `AtomicI64::from_ptr` (Relaxed ordering).
unsafe impl Send for MappingHandle {}
unsafe impl Sync for MappingHandle {}
fn map_view(handle: HANDLE, name: &str, access: u32) -> io::Result<Self> {
let view: MEMORY_MAPPED_VIEW_ADDRESS = unsafe {
MapViewOfFile(handle, access, 0, 0, MockTimeInfo::SIZE)
};
if view.Value.is_null() {
let err = unsafe { GetLastError() } as i32;
unsafe { CloseHandle(handle) };
return Err(io::Error::from_raw_os_error(err));
}
Ok(Self {
handle,
view: view.Value as *mut AtomicI64,
name: name.to_owned(),
})
}
/// Atomically write the delta. Safe for concurrent reads from the hook.
impl MappingHandle {
#[inline]
pub fn write_delta(&self, ticks: i64) {
unsafe { (*self.view).store(ticks, Ordering::Relaxed) }
}
/// Atomically read the delta. Hot path on the hook side.
#[inline]
pub fn read_delta(&self) -> i64 {
unsafe { (*self.view).load(Ordering::Relaxed) }
fn as_atomic(&self) -> &AtomicI64 {
// Safety: view is non-null and 8-byte aligned; sole-purpose memory.
unsafe { AtomicI64::from_ptr(self.view) }
}
}
impl Drop for SharedDelta {
impl Drop for MappingHandle {
fn drop(&mut self) {
unsafe {
if !self.view.is_null() {
@@ -107,3 +63,217 @@ impl Drop for SharedDelta {
}
}
}
unsafe fn map_view(handle: HANDLE, access: u32) -> io::Result<*mut i64> {
let view: MEMORY_MAPPED_VIEW_ADDRESS = MapViewOfFile(handle, access, 0, 0, MockTimeInfo::SIZE);
if view.Value.is_null() {
let err = GetLastError() as i32;
CloseHandle(handle);
return Err(io::Error::from_raw_os_error(err));
}
Ok(view.Value as *mut i64)
}
/// Did `SharedDeltaWriter::create` create a fresh kernel object, or attach to
/// a pre-existing one (typically from a crashed prior controller session)?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreateOutcome {
Fresh,
Existed,
}
/// Read/write handle. Used by the controller to publish the current delta.
pub struct SharedDeltaWriter(MappingHandle);
impl SharedDeltaWriter {
/// Create or attach to the named mapping.
///
/// Returns `(writer, CreateOutcome::Existed)` if the mapping was already
/// present — the caller should log a warning but may proceed (the payload
/// is just an 8-byte delta and will be overwritten).
pub fn create(name: &str) -> io::Result<(Self, CreateOutcome)> {
let wname = wide(name);
let handle = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
ptr::null(),
PAGE_READWRITE,
0,
MockTimeInfo::SIZE as u32,
wname.as_ptr(),
)
};
if handle.is_null() {
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
}
// Capture ERROR_ALREADY_EXISTS *before* any other syscall that may overwrite it.
let outcome = if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
CreateOutcome::Existed
} else {
CreateOutcome::Fresh
};
let view = unsafe { map_view(handle, FILE_MAP_ALL_ACCESS)? };
Ok((Self(MappingHandle { handle, view }), outcome))
}
#[inline]
pub fn write_delta(&self, ticks: i64) {
self.0.as_atomic().store(ticks, Ordering::Relaxed);
}
}
/// Read-only handle. Used by the injected hook DLL on every time-API call.
pub struct SharedDeltaReader(MappingHandle);
impl SharedDeltaReader {
pub fn open(name: &str) -> io::Result<Self> {
let wname = wide(name);
let handle = unsafe { OpenFileMappingW(FILE_MAP_READ, 0, wname.as_ptr()) };
if handle.is_null() {
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
}
let view = unsafe { map_view(handle, FILE_MAP_READ)? };
Ok(Self(MappingHandle { handle, view }))
}
#[inline]
pub fn read_delta(&self) -> i64 {
self.0.as_atomic().load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_mmf_name(tag: &str) -> String {
// Use unprefixed names for tests to avoid Global\ namespace issues
format!("TimeMockerTest_{}_{}", tag, std::process::id())
}
#[test]
fn mmf_write_read_roundtrip_zero() {
let name = test_mmf_name("zero");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for zero test");
assert_eq!(outcome, CreateOutcome::Fresh);
writer.write_delta(0);
let reader = SharedDeltaReader::open(&name).expect("open reader for zero test");
let read_val = reader.read_delta();
assert_eq!(read_val, 0);
}
#[test]
fn mmf_write_read_roundtrip_positive() {
let name = test_mmf_name("positive");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for positive test");
assert_eq!(outcome, CreateOutcome::Fresh);
let test_val: i64 = 1_234_567_890_123_456;
writer.write_delta(test_val);
let reader = SharedDeltaReader::open(&name).expect("open reader for positive test");
let read_val = reader.read_delta();
assert_eq!(read_val, test_val);
}
#[test]
fn mmf_write_read_roundtrip_negative() {
let name = test_mmf_name("negative");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for negative test");
assert_eq!(outcome, CreateOutcome::Fresh);
let test_val: i64 = -1_234_567_890_123_456;
writer.write_delta(test_val);
let reader = SharedDeltaReader::open(&name).expect("open reader for negative test");
let read_val = reader.read_delta();
assert_eq!(read_val, test_val);
}
#[test]
fn mmf_write_read_roundtrip_i64_max() {
let name = test_mmf_name("i64_max");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for i64::MAX test");
assert_eq!(outcome, CreateOutcome::Fresh);
writer.write_delta(i64::MAX);
let reader = SharedDeltaReader::open(&name).expect("open reader for i64::MAX test");
let read_val = reader.read_delta();
assert_eq!(read_val, i64::MAX);
}
#[test]
fn mmf_write_read_roundtrip_i64_min() {
let name = test_mmf_name("i64_min");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for i64::MIN test");
assert_eq!(outcome, CreateOutcome::Fresh);
writer.write_delta(i64::MIN);
let reader = SharedDeltaReader::open(&name).expect("open reader for i64::MIN test");
let read_val = reader.read_delta();
assert_eq!(read_val, i64::MIN);
}
#[test]
fn mmf_detect_preexisting_mapping() {
let name = test_mmf_name("preexist");
let (writer1, outcome1) =
SharedDeltaWriter::create(&name).expect("first create should succeed");
assert_eq!(outcome1, CreateOutcome::Fresh);
// Write a test value via the first writer
let test_val: i64 = 42;
writer1.write_delta(test_val);
// Second create against the same name should detect it exists
let (_writer2, outcome2) =
SharedDeltaWriter::create(&name).expect("second create should succeed");
assert_eq!(outcome2, CreateOutcome::Existed);
// Both writers should alias the same kernel object; read via reader
let reader = SharedDeltaReader::open(&name).expect("open reader for preexist test");
let read_val = reader.read_delta();
assert_eq!(
read_val, test_val,
"readers should observe writes from either writer"
);
}
#[test]
fn mmf_multiple_readers_see_same_value() {
let name = test_mmf_name("multi_reader");
let (writer, outcome) =
SharedDeltaWriter::create(&name).expect("create writer for multi_reader test");
assert_eq!(outcome, CreateOutcome::Fresh);
let test_val: i64 = 99_999_999;
writer.write_delta(test_val);
let reader1 = SharedDeltaReader::open(&name).expect("open reader1");
let reader2 = SharedDeltaReader::open(&name).expect("open reader2");
assert_eq!(reader1.read_delta(), test_val);
assert_eq!(reader2.read_delta(), test_val);
}
#[test]
fn mmf_write_visibility() {
let name = test_mmf_name("write_vis");
let (writer, _) = SharedDeltaWriter::create(&name).expect("create writer");
let reader = SharedDeltaReader::open(&name).expect("open reader");
// Write via writer, immediately read via reader
writer.write_delta(111);
assert_eq!(reader.read_delta(), 111);
writer.write_delta(222);
assert_eq!(reader.read_delta(), 222);
writer.write_delta(-999);
assert_eq!(reader.read_delta(), -999);
}
}
+48
View File
@@ -41,3 +41,51 @@ pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option<i64> {
Some(filetime_to_i64(ft))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filetime_i64_roundtrip() {
// Covers: zero, small, Unix epoch (1970 in FILETIME ticks), a recent time,
// and the i64 boundary (used as a saturating-add sentinel by the hooks).
let cases = [
0_i64,
1,
100,
116_444_736_000_000_000, // 1970-01-01 UTC in FILETIME ticks
132_000_000_000_000_000, // ~2019
i64::MAX,
i64::MIN,
-1,
];
for &t in &cases {
let ft = i64_to_filetime(t);
assert_eq!(filetime_to_i64(ft), t, "round-trip failed for {t}");
}
}
#[test]
fn systemtime_roundtrip_utc() {
// 2020-06-15 12:34:56 UTC
let st = SYSTEMTIME {
wYear: 2020,
wMonth: 6,
wDayOfWeek: 0,
wDay: 15,
wHour: 12,
wMinute: 34,
wSecond: 56,
wMilliseconds: 0,
};
let ticks = systemtime_to_ticks(&st).expect("systemtime_to_ticks");
let back = ticks_to_systemtime(ticks).expect("ticks_to_systemtime");
assert_eq!(back.wYear, st.wYear);
assert_eq!(back.wMonth, st.wMonth);
assert_eq!(back.wDay, st.wDay);
assert_eq!(back.wHour, st.wHour);
assert_eq!(back.wMinute, st.wMinute);
assert_eq!(back.wSecond, st.wSecond);
}
}
+1
View File
@@ -19,6 +19,7 @@ retour = { version = "0.4.0-alpha.4", features = ["static-detour"] }
once_cell = "1.20"
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
+70 -18
View File
@@ -1,47 +1,99 @@
//! `DllMain` and worker-thread bootstrap.
//!
//! On `DLL_PROCESS_ATTACH` we MUST NOT do real work (loader lock). Instead we
//! spawn a worker thread that opens the shared MMF and installs hooks.
//! On `DLL_PROCESS_ATTACH` we MUST NOT do real work under the loader lock.
//! `thread::spawn` lazily wires up Rust's runtime hooks before the loader is
//! unlocked, which can deadlock against any third-party DLL that locks in its
//! `DLL_THREAD_ATTACH`. Instead we use raw `CreateThread` — its thread starts
//! after `DllMain` returns and the new thread's loader-lock interactions
//! (thread-attach callbacks) run cleanly.
//!
//! We also call `DisableThreadLibraryCalls(hinst)` to suppress all future
//! `DLL_THREAD_ATTACH`/`DETACH` notifications for this DLL — we don't need them.
use std::ffi::c_void;
use std::thread;
use std::ffi::{c_void, OsStr};
use std::os::windows::ffi::OsStrExt;
use std::ptr;
use time_mocker_core::{mmf_name_for_pid, SharedDelta};
use windows_sys::Win32::Foundation::{BOOL, HMODULE, TRUE};
use time_mocker_core::{mmf_name_for_pid, SharedDeltaReader};
use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HMODULE, TRUE};
use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW;
use windows_sys::Win32::System::LibraryLoader::DisableThreadLibraryCalls;
use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
use windows_sys::Win32::System::Threading::{CreateThread, GetCurrentProcessId};
use crate::hooks;
use crate::hooks::{self, InstallReport};
#[no_mangle]
#[allow(non_snake_case, clippy::missing_safety_doc)]
pub unsafe extern "system" fn DllMain(
_hinst: HMODULE,
hinst: HMODULE,
reason: u32,
_reserved: *mut c_void,
) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
thread::spawn(bootstrap);
DisableThreadLibraryCalls(hinst);
let thread_handle = CreateThread(
ptr::null(),
0,
Some(bootstrap_thread),
ptr::null(),
0,
ptr::null_mut(),
);
// Close our reference immediately — the thread keeps running until it
// exits naturally, and the kernel reclaims the object when its last
// handle is closed. Without this, one kernel handle leaks per injection.
if !thread_handle.is_null() {
CloseHandle(thread_handle);
}
}
TRUE
}
unsafe extern "system" fn bootstrap_thread(_param: *mut c_void) -> u32 {
bootstrap();
0
}
fn bootstrap() {
let pid = unsafe { GetCurrentProcessId() };
let name = mmf_name_for_pid(pid);
let shared = match SharedDelta::open(&name) {
let shared = match SharedDeltaReader::open(&name) {
Ok(s) => s,
Err(_) => return,
Err(e) => {
// Controller didn't set up the MMF (cross-session DACL, stale inject,
// or never-injected debug case). Surface via OutputDebugStringW for DbgView.
dbg_log(&format!("time-mocker: open MMF '{name}' failed: {e}"));
return;
}
};
if hooks::install(shared).is_err() {
// Hook failure is silent — the target process runs unmodified.
// Best-effort install. Detours stay armed for the lifetime of the host
// process; the bootstrap thread exits immediately after.
let report = hooks::install(shared);
log_install_report(&report);
}
fn log_install_report(report: &InstallReport) {
if report.failed.is_empty() {
dbg_log(&format!(
"time-mocker: installed {} hooks: {:?}",
report.installed.len(),
report.installed
));
return;
}
dbg_log(&format!(
"time-mocker: installed={:?} failed={:?}",
report.installed, report.failed
));
}
// Keep the thread alive; hooks live for the lifetime of the process.
loop {
thread::park();
}
fn dbg_log(msg: &str) {
let wide: Vec<u16> = OsStr::new(msg)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe { OutputDebugStringW(wide.as_ptr()) };
}
+97 -58
View File
@@ -1,19 +1,22 @@
//! Inline detours for 5 Win32 time APIs.
//!
//! All hooks resolve the current FILETIME via the real API, add the shared
//! delta, and return the adjusted value. Reads are atomic and lock-free.
use std::ffi::CStr;
//! Each hook resolves the current FILETIME via the real API (`.call()` on the
//! detour invokes the trampoline, not the detour itself, so no recursion),
//! adds the shared delta, and returns the adjusted value.
//!
//! Install is best-effort: failures are collected per-hook rather than aborting
//! mid-chain, so a single missing export (e.g., `GetSystemTimePreciseAsFileTime`
//! on pre-Win8) does not leave hooks #1-#3 armed while #4-#5 stay real.
use once_cell::sync::OnceCell;
use retour::static_detour;
use time_mocker_core::ticks::{filetime_to_i64, i64_to_filetime, ticks_to_systemtime};
use time_mocker_core::SharedDelta;
use time_mocker_core::ticks::{filetime_to_i64, i64_to_filetime};
use time_mocker_core::SharedDeltaReader;
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
use windows_sys::Win32::System::Time::FileTimeToSystemTime;
use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime};
static SHARED: OnceCell<SharedDelta> = OnceCell::new();
static SHARED: OnceCell<SharedDeltaReader> = OnceCell::new();
static_detour! {
static GetSystemTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME);
@@ -27,39 +30,60 @@ type FnSystemTime = unsafe extern "system" fn(*mut SYSTEMTIME);
type FnFileTime = unsafe extern "system" fn(*mut FILETIME);
type FnNtQuerySystemTime = unsafe extern "system" fn(*mut i64) -> i32;
pub fn install(shared: SharedDelta) -> Result<(), retour::Error> {
/// Per-API outcome from `install`. Surfaces partial-install state for diagnostics.
#[derive(Debug, Default)]
pub struct InstallReport {
pub installed: Vec<&'static str>,
pub failed: Vec<(&'static str, String)>,
}
/// Resolve+initialize+enable a single detour. Pushes to `installed` on success,
/// to `failed` (with reason) on any failure — never aborts the wider install.
macro_rules! install_hook {
($report:ident, $detour:ident, $module:literal, $proc:literal, $fn_ty:ty, $callback:ident) => {{
// Safety: `resolve` is unsafe because it dereferences whatever
// GetProcAddress returns as `$fn_ty`; correctness rests on the
// module+proc literal pair matching `$fn_ty`'s extern signature.
match unsafe { resolve::<$fn_ty>($module, $proc) } {
None => $report
.failed
.push(($proc, "GetProcAddress: not found".into())),
Some(target) => match unsafe { $detour.initialize(target, $callback) } {
Err(e) => $report.failed.push(($proc, format!("initialize: {e}"))),
Ok(d) => match unsafe { d.enable() } {
Err(e) => $report.failed.push(($proc, format!("enable: {e}"))),
Ok(()) => $report.installed.push($proc),
},
},
}
}};
}
pub fn install(shared: SharedDeltaReader) -> InstallReport {
let _ = SHARED.set(shared);
unsafe {
if let Some(target) = resolve::<FnSystemTime>("kernel32.dll", "GetSystemTime") {
GetSystemTimeDetour
.initialize(target, hook_get_system_time)?
.enable()?;
}
if let Some(target) = resolve::<FnSystemTime>("kernel32.dll", "GetLocalTime") {
GetLocalTimeDetour
.initialize(target, hook_get_local_time)?
.enable()?;
}
if let Some(target) = resolve::<FnFileTime>("kernel32.dll", "GetSystemTimeAsFileTime") {
GetSystemTimeAsFileTimeDetour
.initialize(target, hook_get_system_time_as_filetime)?
.enable()?;
}
if let Some(target) =
resolve::<FnFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
{
GetSystemTimePreciseAsFileTimeDetour
.initialize(target, hook_get_system_time_precise_as_filetime)?
.enable()?;
}
if let Some(target) = resolve::<FnNtQuerySystemTime>("ntdll.dll", "NtQuerySystemTime") {
NtQuerySystemTimeDetour
.initialize(target, hook_nt_query_system_time)?
.enable()?;
}
}
Ok(())
let mut report = InstallReport::default();
install_hook!(
report, GetSystemTimeDetour, "kernel32.dll", "GetSystemTime",
FnSystemTime, hook_get_system_time
);
install_hook!(
report, GetLocalTimeDetour, "kernel32.dll", "GetLocalTime",
FnSystemTime, hook_get_local_time
);
install_hook!(
report, GetSystemTimeAsFileTimeDetour, "kernel32.dll", "GetSystemTimeAsFileTime",
FnFileTime, hook_get_system_time_as_filetime
);
install_hook!(
report, GetSystemTimePreciseAsFileTimeDetour, "kernel32.dll", "GetSystemTimePreciseAsFileTime",
FnFileTime, hook_get_system_time_precise_as_filetime
);
install_hook!(
report, NtQuerySystemTimeDetour, "ntdll.dll", "NtQuerySystemTime",
FnNtQuerySystemTime, hook_nt_query_system_time
);
report
}
unsafe fn resolve<F: Copy>(module: &str, proc: &str) -> Option<F> {
@@ -69,7 +93,6 @@ unsafe fn resolve<F: Copy>(module: &str, proc: &str) -> Option<F> {
if h.is_null() {
return None;
}
let _ = CStr::from_bytes_with_nul(b"\0");
let addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?;
Some(std::mem::transmute_copy::<_, F>(&addr))
}
@@ -79,16 +102,12 @@ fn delta() -> i64 {
SHARED.get().map(|s| s.read_delta()).unwrap_or(0)
}
fn fake_filetime() -> FILETIME {
/// Invoke the supplied trampoline to get the real FILETIME, then add the
/// shared delta. Saturating arithmetic — no panic-abort even at i64 bounds.
#[inline]
fn fake_filetime(call_real: impl FnOnce(*mut FILETIME)) -> FILETIME {
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
unsafe { GetSystemTimeAsFileTimeDetour.call(&mut ft) };
let ticks = filetime_to_i64(ft).saturating_add(delta());
i64_to_filetime(ticks)
}
fn fake_filetime_precise() -> FILETIME {
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
unsafe { GetSystemTimePreciseAsFileTimeDetour.call(&mut ft) };
call_real(&mut ft);
let ticks = filetime_to_i64(ft).saturating_add(delta());
i64_to_filetime(ticks)
}
@@ -97,7 +116,7 @@ fn hook_get_system_time(out: *mut SYSTEMTIME) {
if out.is_null() {
return;
}
let ft = fake_filetime();
let ft = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) });
unsafe { FileTimeToSystemTime(&ft, out) };
}
@@ -105,10 +124,19 @@ fn hook_get_local_time(out: *mut SYSTEMTIME) {
if out.is_null() {
return;
}
let ft = fake_filetime();
let ticks = filetime_to_i64(ft);
if let Some(st) = ticks_to_systemtime(ticks) {
unsafe { *out = st };
// Real GetLocalTime applies the timezone offset *of the FILETIME's own date*
// — DST is determined by the source date, not by "today". `FileTimeToLocalFileTime`
// uses today's DST flag, which is wrong when the fake time crosses a DST
// boundary relative to wall-clock. `SystemTimeToTzSpecificLocalTime` with a
// NULL tz pointer uses the active tz AND the source date's DST — what we want.
let ft_utc = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) });
let mut st_utc: SYSTEMTIME = unsafe { std::mem::zeroed() };
if unsafe { FileTimeToSystemTime(&ft_utc, &mut st_utc) } == 0 {
return;
}
if unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &st_utc, out) } == 0 {
// Tz conversion failed — degrade to UTC rather than leaving `out` uninit.
unsafe { *out = st_utc };
}
}
@@ -116,22 +144,33 @@ fn hook_get_system_time_as_filetime(out: *mut FILETIME) {
if out.is_null() {
return;
}
unsafe { *out = fake_filetime() };
let ft = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) });
unsafe { *out = ft };
}
fn hook_get_system_time_precise_as_filetime(out: *mut FILETIME) {
if out.is_null() {
return;
}
unsafe { *out = fake_filetime_precise() };
let ft = fake_filetime(|p| unsafe { GetSystemTimePreciseAsFileTimeDetour.call(p) });
unsafe { *out = ft };
}
/// NTSTATUS for null pointer write — matches what the real NtQuerySystemTime
/// would emit when the kernel dereferences the user buffer.
const STATUS_ACCESS_VIOLATION: i32 = 0xC0000005_u32 as i32;
fn hook_nt_query_system_time(out: *mut i64) -> i32 {
if out.is_null() {
return -1;
return STATUS_ACCESS_VIOLATION;
}
let mut real: i64 = 0;
unsafe { NtQuerySystemTimeDetour.call(&mut real) };
let status = unsafe { NtQuerySystemTimeDetour.call(&mut real) };
if status != 0 {
// Propagate the trampoline's NTSTATUS — otherwise a caller would see
// delta+0 with a bogus STATUS_SUCCESS if the real API ever failed.
return status;
}
unsafe { *out = real.saturating_add(delta()) };
0
}
+4 -2
View File
@@ -25,13 +25,12 @@ sysinfo = "0.32"
globset = "0.4"
regex = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
anyhow = "1.0"
thiserror = "2.0"
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_Security",
"Win32_UI_Shell",
@@ -39,3 +38,6 @@ windows-sys = { workspace = true, features = [
[build-dependencies]
embed-manifest = "1.4"
[dev-dependencies]
tempfile = "3.8"
+251 -43
View File
@@ -14,6 +14,10 @@ use crate::rules::{CompiledRules, PatternKind, Rule};
/// Difference between Unix epoch (1970) and FILETIME epoch (1601), in 100-ns ticks.
const UNIX_TO_FILETIME_TICKS: i64 = 116_444_736_000_000_000;
/// Sane bounds for the date picker — clamp here so `unix_micros * 10` never overflows.
const MIN_YEAR: i32 = 1970;
const MAX_YEAR: i32 = 2200;
#[derive(Default, Serialize, Deserialize)]
struct Persistent {
rules: Vec<Rule>,
@@ -40,9 +44,14 @@ pub struct TimeMockerApp {
search: String,
rule_input: String,
rule_kind: PatternKind,
fake_date: NaiveDate,
fake_time: NaiveTime,
fake_year: i32,
fake_month: u32,
fake_day: u32,
fake_hour: u32,
fake_minute: u32,
fake_second: u32,
current_delta_ticks: i64,
status_msg: Option<String>,
}
impl TimeMockerApp {
@@ -62,6 +71,9 @@ impl TimeMockerApp {
let processes = watcher.list();
let now_local = Local::now();
let date = now_local.date_naive();
let time = now_local.time();
use chrono::{Datelike, Timelike};
Self {
persistent,
tab: Tab::Processes,
@@ -74,9 +86,14 @@ impl TimeMockerApp {
search: String::new(),
rule_input: String::new(),
rule_kind: PatternKind::Glob,
fake_date: now_local.date_naive(),
fake_time: now_local.time(),
fake_year: date.year(),
fake_month: date.month(),
fake_day: date.day(),
fake_hour: time.hour(),
fake_minute: time.minute(),
fake_second: time.second(),
current_delta_ticks: 0,
status_msg: None,
}
}
@@ -84,7 +101,8 @@ impl TimeMockerApp {
if self.last_refresh.elapsed() >= Duration::from_millis(1500) {
self.watcher.refresh();
self.processes = self.watcher.list();
self.processes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.processes
.sort_by_key(|a| a.name.to_lowercase());
if let Some(m) = self.manager.as_mut() {
m.prune_dead(&self.watcher.alive_pids());
}
@@ -102,7 +120,9 @@ impl TimeMockerApp {
self.last_auto_inject_scan = Instant::now();
let compiled = CompiledRules::compile(&self.persistent.rules);
let Some(manager) = self.manager.as_mut() else { return };
let Some(manager) = self.manager.as_mut() else {
return;
};
for proc in &self.processes {
if manager.is_injected(proc.pid) {
@@ -114,64 +134,86 @@ impl TimeMockerApp {
}
}
fn picked_naive_dt(&self) -> Option<chrono::NaiveDateTime> {
let date = NaiveDate::from_ymd_opt(self.fake_year, self.fake_month, self.fake_day)?;
let time = NaiveTime::from_hms_opt(self.fake_hour, self.fake_minute, self.fake_second)?;
Some(date.and_time(time))
}
fn apply_fake_time(&mut self) {
let naive = self.fake_date.and_time(self.fake_time);
let Some(naive) = self.picked_naive_dt() else {
self.status_msg = Some("invalid date/time fields".into());
return;
};
let local: DateTime<Local> = match Local.from_local_datetime(&naive).single() {
Some(dt) => dt,
None => return,
None => {
// DST gap (spring-forward) or ambiguous (fall-back) — surface so the
// user knows the click was a no-op.
self.status_msg =
Some("DST transition: time is ambiguous or skipped — pick a nearby minute".into());
return;
}
};
let utc: DateTime<Utc> = local.with_timezone(&Utc);
let fake_filetime = unix_micros_to_filetime_ticks(utc.timestamp_micros());
let real_filetime = unix_micros_to_filetime_ticks(Utc::now().timestamp_micros());
let Some(fake_filetime) = unix_micros_to_filetime_ticks(utc.timestamp_micros()) else {
self.status_msg = Some("fake time overflows FILETIME range".into());
return;
};
let Some(real_filetime) = unix_micros_to_filetime_ticks(Utc::now().timestamp_micros())
else {
self.status_msg = Some("real time overflows FILETIME range".into());
return;
};
self.current_delta_ticks = fake_filetime - real_filetime;
if let Some(m) = self.manager.as_ref() {
m.set_delta_all(self.current_delta_ticks);
}
self.persistent.last_fake_date = Some(utc.to_rfc3339());
self.status_msg = None;
}
fn reset_to_now(&mut self) {
/// "Now" button — set the picker to current local time and apply, which
/// drives delta ≈ 0 (i.e., disable any mock). Auto-apply matches the
/// label's verb-form ("Now" = "go to now"), not a passive reset.
fn reset_to_now_and_apply(&mut self) {
use chrono::{Datelike, Timelike};
let now = Local::now();
self.fake_date = now.date_naive();
self.fake_time = now.time();
let d = now.date_naive();
let t = now.time();
self.fake_year = d.year();
self.fake_month = d.month();
self.fake_day = d.day();
self.fake_hour = t.hour();
self.fake_minute = t.minute();
self.fake_second = t.second();
self.apply_fake_time();
}
fn ui_top_bar(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.heading("Mock Time");
ui.separator();
let mut y = self.fake_date.format("%Y").to_string();
let mut m = self.fake_date.format("%m").to_string();
let mut d = self.fake_date.format("%d").to_string();
ui.label("Date:");
ui.add(egui::TextEdit::singleline(&mut y).desired_width(48.0));
ui.add(egui::DragValue::new(&mut self.fake_year).range(MIN_YEAR..=MAX_YEAR));
ui.label("-");
ui.add(egui::TextEdit::singleline(&mut m).desired_width(28.0));
ui.add(egui::DragValue::new(&mut self.fake_month).range(1..=12));
ui.label("-");
ui.add(egui::TextEdit::singleline(&mut d).desired_width(28.0));
if let (Ok(yi), Ok(mi), Ok(di)) = (y.parse::<i32>(), m.parse::<u32>(), d.parse::<u32>()) {
if let Some(date) = NaiveDate::from_ymd_opt(yi, mi, di) {
self.fake_date = date;
}
}
// Clamp day to the picked month's max so leap-year/short-month edits
// don't roll over silently.
let max_day = days_in_month(self.fake_year, self.fake_month);
ui.add(egui::DragValue::new(&mut self.fake_day).range(1..=max_day));
ui.label("Time:");
let mut h = self.fake_time.format("%H").to_string();
let mut mn = self.fake_time.format("%M").to_string();
let mut s = self.fake_time.format("%S").to_string();
ui.add(egui::TextEdit::singleline(&mut h).desired_width(28.0));
ui.add(egui::DragValue::new(&mut self.fake_hour).range(0..=23));
ui.label(":");
ui.add(egui::TextEdit::singleline(&mut mn).desired_width(28.0));
ui.add(egui::DragValue::new(&mut self.fake_minute).range(0..=59));
ui.label(":");
ui.add(egui::TextEdit::singleline(&mut s).desired_width(28.0));
if let (Ok(hi), Ok(mi), Ok(si)) = (h.parse::<u32>(), mn.parse::<u32>(), s.parse::<u32>()) {
if let Some(time) = NaiveTime::from_hms_opt(hi, mi, si) {
self.fake_time = time;
}
}
ui.add(egui::DragValue::new(&mut self.fake_second).range(0..=59));
if ui.button("Now").clicked() {
self.reset_to_now();
self.reset_to_now_and_apply();
}
if ui.button("Set").clicked() {
self.apply_fake_time();
@@ -180,6 +222,10 @@ impl TimeMockerApp {
let delta_secs = self.current_delta_ticks as f64 / 10_000_000.0;
ui.label(format!("Δ = {delta_secs:+.1}s"));
});
if let Some(msg) = &self.status_msg {
ui.colored_label(egui::Color32::YELLOW, msg);
}
}
fn ui_processes(&mut self, ui: &mut egui::Ui) {
@@ -189,7 +235,8 @@ impl TimeMockerApp {
if ui.button("⟳ Refresh").clicked() {
self.watcher.refresh();
self.processes = self.watcher.list();
self.processes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.processes
.sort_by_key(|a| a.name.to_lowercase());
}
});
ui.separator();
@@ -203,7 +250,11 @@ impl TimeMockerApp {
let rows: Vec<ProcInfo> = self
.processes
.iter()
.filter(|p| needle.is_empty() || p.name.to_lowercase().contains(&needle) || p.path.to_lowercase().contains(&needle))
.filter(|p| {
needle.is_empty()
|| p.name.to_lowercase().contains(&needle)
|| p.path.to_lowercase().contains(&needle)
})
.cloned()
.collect();
@@ -225,13 +276,18 @@ impl TimeMockerApp {
.map(|m| m.is_injected(p.pid))
.unwrap_or(false);
let mut checked = injected;
let resp = ui.add_enabled(manager_ready, egui::Checkbox::new(&mut checked, ""));
let resp =
ui.add_enabled(manager_ready, egui::Checkbox::new(&mut checked, ""));
if resp.changed() {
if let Some(m) = self.manager.as_mut() {
if checked {
let _ = m.inject(p.pid, &p.name, &p.path, self.current_delta_ticks);
if let Err(e) =
m.inject(p.pid, &p.name, &p.path, self.current_delta_ticks)
{
self.status_msg = Some(format!("inject failed: {e}"));
}
} else {
m.eject(p.pid);
m.disable(p.pid);
}
}
}
@@ -345,7 +401,159 @@ impl eframe::App for TimeMockerApp {
}
}
/// Convert Unix microseconds to FILETIME ticks (100-ns since 1601-01-01 UTC).
/// Returns `None` if either arithmetic step overflows.
#[inline]
fn unix_micros_to_filetime_ticks(unix_micros: i64) -> i64 {
UNIX_TO_FILETIME_TICKS + unix_micros * 10
pub(crate) fn unix_micros_to_filetime_ticks(unix_micros: i64) -> Option<i64> {
unix_micros
.checked_mul(10)
.and_then(|v| UNIX_TO_FILETIME_TICKS.checked_add(v))
}
pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
// Compute via chrono so leap years are correct.
let next_month = if month == 12 { 1 } else { month + 1 };
let next_year = if month == 12 { year + 1 } else { year };
NaiveDate::from_ymd_opt(next_year, next_month, 1)
.and_then(|d| d.pred_opt())
.map(|d| {
use chrono::Datelike;
d.day()
})
.unwrap_or(31)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unix_micros_to_filetime_ticks_zero() {
let result = unix_micros_to_filetime_ticks(0);
assert_eq!(
result,
Some(UNIX_TO_FILETIME_TICKS),
"zero unix micros should map to UNIX_TO_FILETIME_TICKS"
);
}
#[test]
fn unix_micros_to_filetime_ticks_positive() {
// 1 second = 1_000_000 microseconds
// In FILETIME ticks: 1_000_000 * 10 = 10_000_000 ticks
let one_sec_micros = 1_000_000;
let result = unix_micros_to_filetime_ticks(one_sec_micros);
assert!(result.is_some());
let ticks = result.unwrap();
assert_eq!(
ticks,
UNIX_TO_FILETIME_TICKS + 10_000_000,
"one second should add 10M ticks"
);
}
#[test]
fn unix_micros_to_filetime_ticks_large_valid() {
// Year 2100 in Unix micros: roughly 4_102_444_800 seconds = 4_102_444_800_000_000 micros
let year_2100_approx = 4_102_444_800_000_000i64;
let result = unix_micros_to_filetime_ticks(year_2100_approx);
assert!(result.is_some(), "year 2100 should not overflow");
}
#[test]
fn unix_micros_to_filetime_ticks_overflow_on_mul() {
// i64::MAX / 10 ≈ 9.2e17
// Multiplying anything larger by 10 will overflow
let overflow_input = i64::MAX;
let result = unix_micros_to_filetime_ticks(overflow_input);
assert!(
result.is_none(),
"i64::MAX should overflow when multiplied by 10"
);
}
#[test]
fn unix_micros_to_filetime_ticks_overflow_on_add() {
// Create a value that, when multiplied by 10, still fits i64
// but adding UNIX_TO_FILETIME_TICKS causes overflow
// UNIX_TO_FILETIME_TICKS is ~1.16e17, i64::MAX is ~9.2e18
// So we need a very large multiplied value
let _near_max = i64::MAX / 10 - 1; // safe for mul by 10
// This should be OK since (v*10) + offset ≤ i64::MAX
let v = (i64::MAX - UNIX_TO_FILETIME_TICKS + 1) / 10;
let result = unix_micros_to_filetime_ticks(v);
assert!(result.is_some());
}
#[test]
fn days_in_month_feb_leap_2020() {
assert_eq!(days_in_month(2020, 2), 29, "February 2020 is a leap year");
}
#[test]
fn days_in_month_feb_non_leap_2021() {
assert_eq!(days_in_month(2021, 2), 28, "February 2021 is not a leap year");
}
#[test]
fn days_in_month_feb_non_leap_1900() {
// 1900 is divisible by 100 but not 400, so not a leap year
assert_eq!(days_in_month(1900, 2), 28, "February 1900 is not a leap year");
}
#[test]
fn days_in_month_feb_leap_2000() {
// 2000 is divisible by 400, so it is a leap year
assert_eq!(days_in_month(2000, 2), 29, "February 2000 is a leap year");
}
#[test]
fn days_in_month_apr() {
assert_eq!(days_in_month(2024, 4), 30, "April has 30 days");
}
#[test]
fn days_in_month_dec() {
assert_eq!(days_in_month(2024, 12), 31, "December has 31 days");
}
#[test]
fn days_in_month_jan() {
assert_eq!(days_in_month(2024, 1), 31, "January has 31 days");
}
#[test]
fn days_in_month_jun() {
assert_eq!(days_in_month(2024, 6), 30, "June has 30 days");
}
#[test]
fn days_in_month_rollover_dec_to_jan() {
// When month=12, the function computes next_month=1, next_year=year+1
// It should still return 31 for December
assert_eq!(days_in_month(2024, 12), 31);
}
#[test]
fn days_in_month_consistent_with_chrono() {
use chrono::Datelike;
for year in [1970, 2000, 2020, 2024, 2025, 2100] {
for month in 1..=12 {
let result = days_in_month(year, month);
// Verify with chrono
let next_month = if month == 12 { 1 } else { month + 1 };
let next_year = if month == 12 { year + 1 } else { year };
if let Some(first_of_next) = NaiveDate::from_ymd_opt(next_year, next_month, 1) {
if let Some(last_of_month) = first_of_next.pred_opt() {
let expected = last_of_month.day();
assert_eq!(
result, expected,
"days_in_month({}, {}) mismatch",
year, month
);
}
}
}
}
}
}
+268 -12
View File
@@ -2,22 +2,50 @@
//!
//! For each injected PID we keep:
//! - the `dll-syringe` process handle (so the DLL stays loaded)
//! - a `SharedDelta` writer (so we can update the fake time)
//! - a `SharedDeltaWriter` (so we can update the fake time)
//!
//! On UI shutdown, `Drop` zeroes every injected process's delta so the target
//! goes back to real time even though the hook DLL remains loaded.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use dll_syringe::process::OwnedProcess;
use dll_syringe::Syringe;
use time_mocker_core::{mmf_name_for_pid, SharedDelta};
use time_mocker_core::{mmf_name_for_pid, CreateOutcome, SharedDeltaWriter};
use crate::win32_process_info::{is_native_x64, query_full_image_name, IMAGE_FILE_MACHINE_AMD64};
/// Critical Windows processes that must never be injected — they would
/// destabilize the OS, fail with access denied, or trigger AV alerts.
const SYSTEM_PROCESS_EXCLUDE: &[&str] = &[
"system",
"registry",
"memory compression",
"smss.exe",
"csrss.exe",
"wininit.exe",
"winlogon.exe",
"services.exe",
"lsass.exe",
"svchost.exe",
"audiodg.exe",
"dwm.exe",
"fontdrvhost.exe",
"msmpeng.exe",
"nissrv.exe",
"securityhealthservice.exe",
];
const LOG_CAP: usize = 1000;
#[allow(dead_code)]
pub struct InjectedProcess {
pub pid: u32,
pub name: String,
pub path: String,
delta: SharedDelta,
delta: SharedDeltaWriter,
_syringe: Syringe,
}
@@ -30,7 +58,7 @@ impl InjectedProcess {
pub struct InjectionManager {
injected: HashMap<u32, InjectedProcess>,
hook_dll_path: PathBuf,
pub log: Vec<String>,
pub log: VecDeque<String>,
}
impl InjectionManager {
@@ -40,10 +68,23 @@ impl InjectionManager {
.and_then(|p| p.parent().map(Path::to_path_buf))
.ok_or_else(|| anyhow!("cannot resolve exe directory"))?;
let hook_dll_path = exe_dir.join("time_mocker_hook.dll");
// Validate the hook DLL's architecture once at startup so we fail
// loudly rather than producing opaque dll-syringe errors at inject time.
if hook_dll_path.exists() {
let machine = crate::win32_process_info::pe_machine(&hook_dll_path)
.with_context(|| format!("read PE header of {}", hook_dll_path.display()))?;
if machine != IMAGE_FILE_MACHINE_AMD64 {
return Err(anyhow!(
"hook DLL machine={machine:#x}, expected AMD64 ({IMAGE_FILE_MACHINE_AMD64:#x})"
));
}
}
Ok(Self {
injected: HashMap::new(),
hook_dll_path,
log: Vec::new(),
log: VecDeque::new(),
})
}
@@ -61,6 +102,19 @@ impl InjectionManager {
}
pub fn inject(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
match self.inject_inner(pid, name, path, initial_delta) {
Ok(()) => Ok(()),
Err(e) => {
// Auto-inject scanner discards inject Errs; logging here makes
// every failure (including silent auto-inject loops) visible in
// the Log tab without forcing every caller to handle the Err.
self.log_push(format!("inject pid={pid} ({name}) failed: {e:#}"));
Err(e)
}
}
}
fn inject_inner(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
if self.injected.contains_key(&pid) {
return Ok(());
}
@@ -71,9 +125,41 @@ impl InjectionManager {
));
}
// PID-reuse guard: between the watcher snapshot and now, the PID may
// have been recycled. Verify the image path still matches; derive the
// LIVE filename from the live path so the system-process check below
// doesn't trust the (possibly stale) snapshot name.
let live_path = query_full_image_name(pid)
.with_context(|| format!("query image name for pid={pid}"))?;
if !paths_equivalent(&live_path, path) {
return Err(anyhow!(
"pid={pid} image mismatch: expected `{path}`, got `{live_path}` (PID reuse?)"
));
}
let live_name = Path::new(&live_path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| name.to_owned());
if is_system_process(&live_name) {
return Err(anyhow!(
"refusing to inject system process {live_name} (pid={pid})"
));
}
if !is_native_x64(pid) {
return Err(anyhow!(
"pid={pid} is not a native x64 process; the AMD64 hook DLL cannot be injected"
));
}
let mmf_name = mmf_name_for_pid(pid);
let delta = SharedDelta::create(&mmf_name)
let (delta, outcome) = SharedDeltaWriter::create(&mmf_name)
.with_context(|| format!("create MMF {mmf_name}"))?;
if outcome == CreateOutcome::Existed {
self.log_push(format!(
"warn: MMF {mmf_name} pre-existed (stale prior session?)"
));
}
delta.write_delta(initial_delta);
let process = OwnedProcess::from_pid(pid)
@@ -83,8 +169,7 @@ impl InjectionManager {
.inject(&self.hook_dll_path)
.with_context(|| format!("inject {} into pid={}", self.hook_dll_path.display(), pid))?;
self.log
.push(format!("Injected into [{pid}] {name}"));
self.log_push(format!("Injected into [{pid}] {name}"));
self.injected.insert(
pid,
InjectedProcess {
@@ -104,11 +189,13 @@ impl InjectionManager {
}
}
pub fn eject(&mut self, pid: u32) {
/// Zero the delta and drop our handle. The DLL stays loaded in the target
/// (dll-syringe `eject` is not wired up — see report Q2); from the target's
/// perspective time is back to real once the delta is zero.
pub fn disable(&mut self, pid: u32) {
if let Some(p) = self.injected.remove(&pid) {
// Best-effort: writing 0 restores real time even if the DLL stays loaded.
p.write_delta(0);
self.log.push(format!("Ejected [{pid}] {}", p.name));
self.log_push(format!("Disabled [{pid}] {}", p.name));
}
}
@@ -124,4 +211,173 @@ impl InjectionManager {
self.injected.remove(&pid);
}
}
fn log_push(&mut self, line: String) {
self.log.push_back(line);
while self.log.len() > LOG_CAP {
self.log.pop_front();
}
}
}
impl Drop for InjectionManager {
fn drop(&mut self) {
// Best-effort: zero every injected process's delta so targets return
// to real time on UI exit. The MMF handles are dropped right after.
for proc in self.injected.values() {
proc.write_delta(0);
}
}
}
fn is_system_process(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
SYSTEM_PROCESS_EXCLUDE.iter().any(|n| *n == lower)
}
pub(crate) fn paths_equivalent(a: &str, b: &str) -> bool {
// Use Unicode-aware lowercasing so non-ASCII case differences (accented Latin,
// Cyrillic, CJK) don't yield false-negative "PID reuse?" errors on i18n paths.
// The two allocations are amortized — called once per inject, never in hot path.
a.to_lowercase() == b.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_system_process_csrss() {
assert!(is_system_process("csrss.exe"));
}
#[test]
fn is_system_process_csrss_uppercase() {
assert!(is_system_process("CSRSS.EXE"));
}
#[test]
fn is_system_process_csrss_mixedcase() {
assert!(is_system_process("CsRsS.ExE"));
}
#[test]
fn is_system_process_system() {
assert!(is_system_process("system"));
}
#[test]
fn is_system_process_system_uppercase() {
assert!(is_system_process("SYSTEM"));
}
#[test]
fn is_system_process_registry() {
assert!(is_system_process("registry"));
}
#[test]
fn is_system_process_svchost() {
assert!(is_system_process("svchost.exe"));
}
#[test]
fn is_system_process_dwm() {
assert!(is_system_process("dwm.exe"));
}
#[test]
fn is_system_process_msmpeng() {
assert!(is_system_process("msmpeng.exe"));
}
#[test]
fn is_system_process_non_system() {
assert!(!is_system_process("notepad.exe"));
}
#[test]
fn is_system_process_non_system_uppercase() {
assert!(!is_system_process("NOTEPAD.EXE"));
}
#[test]
fn is_system_process_myapp() {
assert!(!is_system_process("MyApp.exe"));
}
#[test]
fn is_system_process_empty_string() {
assert!(!is_system_process(""));
}
#[test]
fn is_system_process_partial_match_should_not_match() {
// "csrss" (without .exe) should not match "csrss.exe"
assert!(!is_system_process("csrss"));
}
#[test]
fn paths_equivalent_same() {
assert!(paths_equivalent("C:\\foo\\bar.exe", "C:\\foo\\bar.exe"));
}
#[test]
fn paths_equivalent_case_insensitive() {
assert!(paths_equivalent("C:\\Foo\\Bar.exe", "c:\\foo\\bar.exe"));
}
#[test]
fn paths_equivalent_mixed_case() {
assert!(paths_equivalent(
"C:\\Windows\\System32\\notepad.exe",
"c:\\windows\\system32\\NOTEPAD.EXE"
));
}
#[test]
fn paths_equivalent_different_paths() {
assert!(!paths_equivalent("C:\\foo\\bar.exe", "C:\\baz\\bar.exe"));
}
#[test]
fn paths_equivalent_different_filenames() {
assert!(!paths_equivalent("C:\\foo\\bar.exe", "C:\\foo\\baz.exe"));
}
#[test]
fn paths_equivalent_empty_strings() {
assert!(paths_equivalent("", ""));
}
#[test]
fn paths_equivalent_one_empty() {
assert!(!paths_equivalent("C:\\foo.exe", ""));
}
#[test]
fn injection_manager_log_bounded() {
let mut manager = InjectionManager {
injected: HashMap::new(),
hook_dll_path: std::path::PathBuf::from("dummy.dll"),
log: VecDeque::new(),
};
// Push LOG_CAP + 100 entries and verify only LOG_CAP remain
for i in 0..(LOG_CAP + 100) {
manager.log_push(format!("line {}", i));
}
assert_eq!(
manager.log.len(),
LOG_CAP,
"log should stay bounded at LOG_CAP"
);
// First message should be gone (front was popped)
let first_kept = manager.log.front().unwrap();
assert!(
first_kept.contains("line 100"),
"oldest entry should be from the 100th push"
);
}
}
+1
View File
@@ -12,6 +12,7 @@ mod app;
mod injection_manager;
mod process_watcher;
mod rules;
mod win32_process_info;
use anyhow::Result;
@@ -0,0 +1,190 @@
//! Thin Win32 helpers used by the injection manager:
//! - PE machine field of an on-disk DLL (architecture validation)
//! - QueryFullProcessImageName for live PID-reuse detection
//! - IsWow64Process2 for target-bitness check
use std::ffi::OsString;
use std::fs::File;
use std::io::{self, Read};
use std::os::windows::ffi::OsStringExt;
use std::path::Path;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
IsWow64Process2, OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
pub const IMAGE_FILE_MACHINE_UNKNOWN: u16 = 0;
pub const IMAGE_FILE_MACHINE_AMD64: u16 = 0x8664;
/// Read the COFF Machine field from an on-disk PE (DLL or EXE).
///
/// Reads up to 64 KiB so packed / obfuscated PEs with large `e_lfanew` values
/// (typical PE files have `e_lfanew` ≈ 0x40..0x200, but the spec permits much
/// larger) still parse cleanly.
pub fn pe_machine(path: &Path) -> io::Result<u16> {
let mut file = File::open(path)?;
let mut buf = vec![0u8; 64 * 1024];
let mut filled = 0usize;
while filled < buf.len() {
match file.read(&mut buf[filled..])? {
0 => break,
n => filled += n,
}
}
let n = filled;
if n < 0x40 || &buf[0..2] != b"MZ" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "not a PE file"));
}
let e_lfanew = u32::from_le_bytes([buf[0x3C], buf[0x3D], buf[0x3E], buf[0x3F]]) as usize;
if e_lfanew.saturating_add(6) > n || &buf[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "missing PE signature"));
}
Ok(u16::from_le_bytes([buf[e_lfanew + 4], buf[e_lfanew + 5]]))
}
/// Return the full image path of a live process, or `Err` if the process
/// is gone / inaccessible. Used to detect PID reuse between watcher
/// refresh and inject.
pub fn query_full_image_name(pid: u32) -> io::Result<String> {
unsafe {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return Err(io::Error::last_os_error());
}
let mut buf = [0u16; 1024];
let mut size = buf.len() as u32;
let ok = QueryFullProcessImageNameW(h, 0, buf.as_mut_ptr(), &mut size);
CloseHandle(h);
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(OsString::from_wide(&buf[..size as usize])
.to_string_lossy()
.into_owned())
}
}
/// True iff the target process is native AMD64 (not WOW64). The hook DLL is
/// AMD64-only; injecting it into a 32-bit WOW64 process produces an opaque
/// dll-syringe error well after the user committed.
pub fn is_native_x64(pid: u32) -> bool {
unsafe {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return false;
}
let mut process_machine: u16 = 0;
let mut native_machine: u16 = 0;
let ok = IsWow64Process2(h, &mut process_machine, &mut native_machine);
CloseHandle(h);
if ok == 0 {
return false;
}
// Native process: process_machine == UNKNOWN. Native arch must be AMD64.
process_machine == IMAGE_FILE_MACHINE_UNKNOWN && native_machine == IMAGE_FILE_MACHINE_AMD64
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn pe_machine_reads_amd64() {
// Build path: ../../../target/release/time_mocker_hook.dll
let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let dll_path = std::path::PathBuf::from(&manifest)
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("target/release/time_mocker_hook.dll"))
.expect("derive target/release path");
// Skip test if DLL not found (e.g., release build not run yet)
if !dll_path.exists() {
eprintln!(
"Skipping pe_machine test: DLL not found at {}",
dll_path.display()
);
return;
}
let machine = pe_machine(&dll_path).expect("read PE machine from hook DLL");
assert_eq!(
machine, IMAGE_FILE_MACHINE_AMD64,
"hook DLL must be AMD64 (0x8664), got {:#x}",
machine
);
}
#[test]
fn pe_machine_rejects_short_file() {
use tempfile::NamedTempFile;
let mut f = NamedTempFile::new().expect("create temp file");
f.write_all(b"MZ").expect("write short MZ header");
f.flush().expect("flush");
let result = pe_machine(f.path());
assert!(
result.is_err(),
"should reject file shorter than PE header offset"
);
}
#[test]
fn pe_machine_rejects_no_mz() {
use tempfile::NamedTempFile;
let mut f = NamedTempFile::new().expect("create temp file");
f.write_all(&[0u8; 0x40]).expect("write 64 zero bytes");
f.flush().expect("flush");
let result = pe_machine(f.path());
assert!(result.is_err(), "should reject file without MZ signature");
}
#[test]
fn pe_machine_rejects_invalid_e_lfanew() {
use tempfile::NamedTempFile;
let mut f = NamedTempFile::new().expect("create temp file");
let mut buf = [0u8; 0x40];
buf[0..2].copy_from_slice(b"MZ");
// e_lfanew at offset 0x3C: set to a huge offset that exceeds file size
buf[0x3C..0x40].copy_from_slice(&0x10000_u32.to_le_bytes());
f.write_all(&buf).expect("write header");
f.flush().expect("flush");
let result = pe_machine(f.path());
assert!(
result.is_err(),
"should reject file with e_lfanew beyond file bounds"
);
}
#[test]
fn pe_machine_rejects_missing_pe_signature() {
use tempfile::NamedTempFile;
let mut f = NamedTempFile::new().expect("create temp file");
let mut buf = [0u8; 512];
buf[0..2].copy_from_slice(b"MZ");
// e_lfanew = 0x40 (valid offset)
buf[0x3C..0x40].copy_from_slice(&0x40_u32.to_le_bytes());
// Don't write PE signature at 0x40, leave zeros
f.write_all(&buf).expect("write header");
f.flush().expect("flush");
let result = pe_machine(f.path());
assert!(result.is_err(), "should reject file without PE signature");
}
#[test]
fn is_native_x64_self() {
// Test on the current process (which must be native x64 if tests run)
let self_pid = std::process::id();
let result = is_native_x64(self_pid);
// If we're running in x64 mode, this should be true
#[cfg(target_arch = "x86_64")]
assert!(result, "self process (x64) should report as native x64");
// x86 builds would report false, but we're x64-only for this project
}
}