diff --git a/Cargo.lock b/Cargo.lock index 8d63190..74309b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/time-mocker-core/src/lib.rs b/crates/time-mocker-core/src/lib.rs index 2bb3571..dc3c636 100644 --- a/crates/time-mocker-core/src/lib.rs +++ b/crates/time-mocker-core/src/lib.rs @@ -1,8 +1,12 @@ //! Shared types and named MMF helper for time-mocker. //! -//! IPC contract: an 8-byte memory-mapped file named `TimeMocker_` 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_` +//! 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 { diff --git a/crates/time-mocker-core/src/mmf.rs b/crates/time-mocker-core/src/mmf.rs index 53c01e8..9e4170e 100644 --- a/crates/time-mocker-core/src/mmf.rs +++ b/crates/time-mocker-core/src/mmf.rs @@ -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_` 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 { 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 { - 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 { - 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 { - 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 { + 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); + } +} diff --git a/crates/time-mocker-core/src/ticks.rs b/crates/time-mocker-core/src/ticks.rs index 7809738..b853817 100644 --- a/crates/time-mocker-core/src/ticks.rs +++ b/crates/time-mocker-core/src/ticks.rs @@ -41,3 +41,51 @@ pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option { 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); + } +} diff --git a/crates/time-mocker-hook/Cargo.toml b/crates/time-mocker-hook/Cargo.toml index 527dbcb..857f8ba 100644 --- a/crates/time-mocker-hook/Cargo.toml +++ b/crates/time-mocker-hook/Cargo.toml @@ -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", diff --git a/crates/time-mocker-hook/src/entrypoint.rs b/crates/time-mocker-hook/src/entrypoint.rs index 2907e5f..dadfc40 100644 --- a/crates/time-mocker-hook/src/entrypoint.rs +++ b/crates/time-mocker-hook/src/entrypoint.rs @@ -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 = OsStr::new(msg) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { OutputDebugStringW(wide.as_ptr()) }; } diff --git a/crates/time-mocker-hook/src/hooks.rs b/crates/time-mocker-hook/src/hooks.rs index 409c673..2bd8abf 100644 --- a/crates/time-mocker-hook/src/hooks.rs +++ b/crates/time-mocker-hook/src/hooks.rs @@ -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 = OnceCell::new(); +static SHARED: OnceCell = 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::("kernel32.dll", "GetSystemTime") { - GetSystemTimeDetour - .initialize(target, hook_get_system_time)? - .enable()?; - } - if let Some(target) = resolve::("kernel32.dll", "GetLocalTime") { - GetLocalTimeDetour - .initialize(target, hook_get_local_time)? - .enable()?; - } - if let Some(target) = resolve::("kernel32.dll", "GetSystemTimeAsFileTime") { - GetSystemTimeAsFileTimeDetour - .initialize(target, hook_get_system_time_as_filetime)? - .enable()?; - } - if let Some(target) = - resolve::("kernel32.dll", "GetSystemTimePreciseAsFileTime") - { - GetSystemTimePreciseAsFileTimeDetour - .initialize(target, hook_get_system_time_precise_as_filetime)? - .enable()?; - } - if let Some(target) = resolve::("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(module: &str, proc: &str) -> Option { @@ -69,7 +93,6 @@ unsafe fn resolve(module: &str, proc: &str) -> Option { 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 } diff --git a/crates/time-mocker-ui/Cargo.toml b/crates/time-mocker-ui/Cargo.toml index 9184c7e..e531467 100644 --- a/crates/time-mocker-ui/Cargo.toml +++ b/crates/time-mocker-ui/Cargo.toml @@ -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" diff --git a/crates/time-mocker-ui/src/app.rs b/crates/time-mocker-ui/src/app.rs index 7052da5..64bfd57 100644 --- a/crates/time-mocker-ui/src/app.rs +++ b/crates/time-mocker-ui/src/app.rs @@ -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, @@ -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, } 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 { + 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 = 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 = 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::(), m.parse::(), d.parse::()) { - 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::(), mn.parse::(), s.parse::()) { - 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 = 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 { + 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 + ); + } + } + } + } + } } diff --git a/crates/time-mocker-ui/src/injection_manager.rs b/crates/time-mocker-ui/src/injection_manager.rs index a91b710..5bafe30 100644 --- a/crates/time-mocker-ui/src/injection_manager.rs +++ b/crates/time-mocker-ui/src/injection_manager.rs @@ -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, hook_dll_path: PathBuf, - pub log: Vec, + pub log: VecDeque, } 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" + ); + } } diff --git a/crates/time-mocker-ui/src/main.rs b/crates/time-mocker-ui/src/main.rs index 838dfc7..6a6c16e 100644 --- a/crates/time-mocker-ui/src/main.rs +++ b/crates/time-mocker-ui/src/main.rs @@ -12,6 +12,7 @@ mod app; mod injection_manager; mod process_watcher; mod rules; +mod win32_process_info; use anyhow::Result; diff --git a/crates/time-mocker-ui/src/win32_process_info.rs b/crates/time-mocker-ui/src/win32_process_info.rs new file mode 100644 index 0000000..c3b9a60 --- /dev/null +++ b/crates/time-mocker-ui/src/win32_process_info.rs @@ -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 { + 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 { + 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 + } +}