mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-08-07 12:24:12 +00:00
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:
Generated
+21
-28
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user