mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-10-11 03:13:47 +00:00
feat: initial Rust port of time-mocker
Rust workspace with three crates: - time-mocker-core: shared MockTimeInfo + named MMF helper + FILETIME helpers - time-mocker-hook: cdylib injected into targets; inline detours via retour on GetSystemTime / GetLocalTime / GetSystemTimeAsFileTime / GetSystemTimePreciseAsFileTime / NtQuerySystemTime - time-mocker-ui: egui controller with process list, time picker, and glob/regex auto-inject rules; injects via dll-syringe; UAC manifest embedded in release builds IPC: 8-byte MMF named TimeMocker_<pid> holding an i64 delta in FILETIME ticks. Hook adds delta to the real FILETIME on every call. Note this differs from the C# version's .NET-tick delta -- contracts are not interoperable. Requires nightly toolchain (retour uses unboxed_closures / tuple_trait); pinned via rust-toolchain.toml.
This commit is contained in:
1 parent
581785d90e
commit
001cf53c37
22 files changed
+4798
No files matched your search
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local config
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Plans (we keep the C# project's plans separate)
|
||||||
|
/plans/
|
||||||
Generated
+3471
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/time-mocker-core",
|
||||||
|
"crates/time-mocker-hook",
|
||||||
|
"crates/time-mocker-ui",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["tiennm99"]
|
||||||
|
repository = "https://github.com/tiennm99/time-mocker-rs"
|
||||||
|
rust-version = "1.90"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
windows = { version = "0.58", default-features = false }
|
||||||
|
windows-sys = "0.59"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
|
opt-level = 3
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 tiennm99
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# TimeMocker (Rust)
|
||||||
|
|
||||||
|
A Windows tool that injects fake time into running processes by hooking Win32 time APIs. Rust port of [time-mocker](https://github.com/tiennm99/time-mocker) (C# / EasyHook).
|
||||||
|
|
||||||
|
> **Status:** Active development. This Rust implementation is intended to become the canonical `time-mocker` once feature-complete; the C# project will be renamed to indicate its dotnet origin.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
time-mocker-rs/
|
||||||
|
├── crates/
|
||||||
|
│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions
|
||||||
|
│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour
|
||||||
|
│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooked APIs
|
||||||
|
|
||||||
|
| API | DLL |
|
||||||
|
|-----|-----|
|
||||||
|
| `GetSystemTime` | kernel32 |
|
||||||
|
| `GetLocalTime` | kernel32 |
|
||||||
|
| `GetSystemTimeAsFileTime` | kernel32 |
|
||||||
|
| `GetSystemTimePreciseAsFileTime` | kernel32 |
|
||||||
|
| `NtQuerySystemTime` | ntdll |
|
||||||
|
|
||||||
|
## IPC Design
|
||||||
|
|
||||||
|
Named Memory-Mapped File per injected process:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: TimeMocker_<PID>
|
||||||
|
Size: 8 bytes
|
||||||
|
[0..7] DeltaTicks (i64 — 100-ns units, added to the real FILETIME)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook reads the delta on every time API call and returns `real_filetime + delta`. The controller writes the delta whenever the user picks a new fake time.
|
||||||
|
|
||||||
|
> **Tick epoch difference vs the C# version:** The C# version stores a delta against `DateTime.UtcNow.Ticks` (epoch 0001-01-01 UTC). The Rust version stores a delta in raw FILETIME units (epoch 1601-01-01 UTC). The two IPC contracts are not interoperable — the Rust UI and Rust hook DLL only talk to each other.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Nightly Rust (required by retour for inline x64 detours)
|
||||||
|
# A `rust-toolchain.toml` at the repo root pins the channel automatically.
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Outputs:
|
||||||
|
# target/release/time_mocker_ui.exe
|
||||||
|
# target/release/time_mocker_hook.dll (must be next to the UI exe)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows 10/11 x64
|
||||||
|
- Rust nightly (pinned via `rust-toolchain.toml`)
|
||||||
|
- Must run as Administrator (UAC manifest embedded)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [time-mocker](https://github.com/tiennm99/time-mocker) — original C# / EasyHook implementation
|
||||||
|
- [time-mocker-cpp](https://github.com/tiennm99/time-mocker-cpp) — C++ / MS Detours port
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "time-mocker-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Shared types and named MMF helper for time-mocker"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "time_mocker_core"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
windows-sys = { workspace = true, features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Time",
|
||||||
|
"Win32_Security",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
pub mod mmf;
|
||||||
|
pub mod ticks;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use mmf::SharedDelta;
|
||||||
|
pub use types::MockTimeInfo;
|
||||||
|
|
||||||
|
pub const MMF_PREFIX: &str = "TimeMocker_";
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn mmf_name_for_pid(pid: u32) -> String {
|
||||||
|
format!("{MMF_PREFIX}{pid}")
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::io;
|
||||||
|
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::System::Memory::{
|
||||||
|
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||||
|
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
#[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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SharedDelta {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
if !self.view.is_null() {
|
||||||
|
let addr = MEMORY_MAPPED_VIEW_ADDRESS {
|
||||||
|
Value: self.view as *mut _,
|
||||||
|
};
|
||||||
|
UnmapViewOfFile(addr);
|
||||||
|
}
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
CloseHandle(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! FILETIME / SYSTEMTIME conversion helpers.
|
||||||
|
//!
|
||||||
|
//! FILETIME = 100-ns units since 1601-01-01 00:00:00 UTC. This crate uses
|
||||||
|
//! FILETIME ticks throughout to avoid extra arithmetic on the hot path.
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||||
|
use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToFileTime};
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn filetime_to_i64(ft: FILETIME) -> i64 {
|
||||||
|
((ft.dwHighDateTime as i64) << 32) | (ft.dwLowDateTime as i64 & 0xFFFF_FFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn i64_to_filetime(ticks: i64) -> FILETIME {
|
||||||
|
FILETIME {
|
||||||
|
dwLowDateTime: (ticks & 0xFFFF_FFFF) as u32,
|
||||||
|
dwHighDateTime: ((ticks >> 32) & 0xFFFF_FFFF) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FILETIME ticks to SYSTEMTIME (UTC). Returns None on Win32 failure.
|
||||||
|
pub fn ticks_to_systemtime(ticks: i64) -> Option<SYSTEMTIME> {
|
||||||
|
let ft = i64_to_filetime(ticks);
|
||||||
|
let mut st: SYSTEMTIME = unsafe { std::mem::zeroed() };
|
||||||
|
let ok = unsafe { FileTimeToSystemTime(&ft, &mut st) };
|
||||||
|
if ok == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert SYSTEMTIME (UTC) to FILETIME ticks. Returns None on Win32 failure.
|
||||||
|
pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option<i64> {
|
||||||
|
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
|
||||||
|
let ok = unsafe { SystemTimeToFileTime(st, &mut ft) };
|
||||||
|
if ok == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(filetime_to_i64(ft))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use std::mem::size_of;
|
||||||
|
|
||||||
|
/// 8-byte payload of the shared MMF.
|
||||||
|
///
|
||||||
|
/// `delta_ticks` is added to the real FILETIME (100-ns since 1601-01-01 UTC)
|
||||||
|
/// by every hooked time API call. May be negative to mock past times.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct MockTimeInfo {
|
||||||
|
pub delta_ticks: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockTimeInfo {
|
||||||
|
pub const SIZE: usize = size_of::<Self>();
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub const fn zero() -> Self {
|
||||||
|
Self { delta_ticks: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: () = assert!(MockTimeInfo::SIZE == 8);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "time-mocker-hook"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Injected DLL that hooks Win32 time APIs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "time_mocker_hook"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
time-mocker-core = { path = "../time-mocker-core" }
|
||||||
|
retour = { version = "0.4.0-alpha.4", features = ["static-detour"] }
|
||||||
|
once_cell = "1.20"
|
||||||
|
windows-sys = { workspace = true, features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_SystemServices",
|
||||||
|
"Win32_System_Time",
|
||||||
|
] }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! `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.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use time_mocker_core::{mmf_name_for_pid, SharedDelta};
|
||||||
|
use windows_sys::Win32::Foundation::{BOOL, HMODULE, TRUE};
|
||||||
|
use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
||||||
|
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||||
|
|
||||||
|
use crate::hooks;
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[allow(non_snake_case, clippy::missing_safety_doc)]
|
||||||
|
pub unsafe extern "system" fn DllMain(
|
||||||
|
_hinst: HMODULE,
|
||||||
|
reason: u32,
|
||||||
|
_reserved: *mut c_void,
|
||||||
|
) -> BOOL {
|
||||||
|
if reason == DLL_PROCESS_ATTACH {
|
||||||
|
thread::spawn(bootstrap);
|
||||||
|
}
|
||||||
|
TRUE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bootstrap() {
|
||||||
|
let pid = unsafe { GetCurrentProcessId() };
|
||||||
|
let name = mmf_name_for_pid(pid);
|
||||||
|
|
||||||
|
let shared = match SharedDelta::open(&name) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if hooks::install(shared).is_err() {
|
||||||
|
// Hook failure is silent — the target process runs unmodified.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the thread alive; hooks live for the lifetime of the process.
|
||||||
|
loop {
|
||||||
|
thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
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 windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||||
|
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||||
|
use windows_sys::Win32::System::Time::FileTimeToSystemTime;
|
||||||
|
|
||||||
|
static SHARED: OnceCell<SharedDelta> = OnceCell::new();
|
||||||
|
|
||||||
|
static_detour! {
|
||||||
|
static GetSystemTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME);
|
||||||
|
static GetLocalTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME);
|
||||||
|
static GetSystemTimeAsFileTimeDetour: unsafe extern "system" fn(*mut FILETIME);
|
||||||
|
static GetSystemTimePreciseAsFileTimeDetour: unsafe extern "system" fn(*mut FILETIME);
|
||||||
|
static NtQuerySystemTimeDetour: unsafe extern "system" fn(*mut i64) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn resolve<F: Copy>(module: &str, proc: &str) -> Option<F> {
|
||||||
|
let module_c = std::ffi::CString::new(module).ok()?;
|
||||||
|
let proc_c = std::ffi::CString::new(proc).ok()?;
|
||||||
|
let h = GetModuleHandleA(module_c.as_ptr() as *const u8);
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn delta() -> i64 {
|
||||||
|
SHARED.get().map(|s| s.read_delta()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_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) };
|
||||||
|
let ticks = filetime_to_i64(ft).saturating_add(delta());
|
||||||
|
i64_to_filetime(ticks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_get_system_time(out: *mut SYSTEMTIME) {
|
||||||
|
if out.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ft = fake_filetime();
|
||||||
|
unsafe { FileTimeToSystemTime(&ft, out) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_get_system_time_as_filetime(out: *mut FILETIME) {
|
||||||
|
if out.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsafe { *out = fake_filetime() };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_get_system_time_precise_as_filetime(out: *mut FILETIME) {
|
||||||
|
if out.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsafe { *out = fake_filetime_precise() };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_nt_query_system_time(out: *mut i64) -> i32 {
|
||||||
|
if out.is_null() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let mut real: i64 = 0;
|
||||||
|
unsafe { NtQuerySystemTimeDetour.call(&mut real) };
|
||||||
|
unsafe { *out = real.saturating_add(delta()) };
|
||||||
|
0
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Injected DLL — hooks Win32 time APIs and rewrites them to read a shared delta.
|
||||||
|
//!
|
||||||
|
//! Implementation lives in `hooks.rs` and `entrypoint.rs`.
|
||||||
|
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
mod entrypoint;
|
||||||
|
mod hooks;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "time-mocker-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Controller UI for time-mocker — picks fake time and injects hook DLL into target processes"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "time_mocker_ui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
time-mocker-core = { path = "../time-mocker-core" }
|
||||||
|
eframe = { version = "0.29", default-features = false, features = [
|
||||||
|
"default_fonts",
|
||||||
|
"glow",
|
||||||
|
"persistence",
|
||||||
|
] }
|
||||||
|
egui = "0.29"
|
||||||
|
dll-syringe = "0.17"
|
||||||
|
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_Threading",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-manifest = "1.4"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Embeds a Windows UAC manifest so the controller prompts for admin
|
||||||
|
//! elevation on launch (required for `CreateRemoteThread` into other users'
|
||||||
|
//! processes and for hooking system DLLs).
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Only embed the UAC manifest in release builds — otherwise `cargo test`
|
||||||
|
// and other dev workflows would fail with ERROR_ELEVATION_REQUIRED (740).
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||||
|
if profile != "release" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use embed_manifest::manifest::ExecutionLevel;
|
||||||
|
use embed_manifest::{embed_manifest, new_manifest};
|
||||||
|
let manifest = new_manifest("TimeMocker.UI")
|
||||||
|
.requested_execution_level(ExecutionLevel::RequireAdministrator);
|
||||||
|
embed_manifest(manifest).expect("failed to embed UAC manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! `eframe::App` impl — three tabs (Processes, Auto-Inject Rules, Log) and a
|
||||||
|
//! global Mock Time bar at the top.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||||
|
use eframe::{egui, CreationContext};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::injection_manager::InjectionManager;
|
||||||
|
use crate::process_watcher::{ProcInfo, ProcessWatcher};
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Persistent {
|
||||||
|
rules: Vec<Rule>,
|
||||||
|
auto_inject_enabled: bool,
|
||||||
|
last_fake_date: Option<String>, // ISO-8601 of last applied UTC fake time
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
|
enum Tab {
|
||||||
|
Processes,
|
||||||
|
Rules,
|
||||||
|
Log,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimeMockerApp {
|
||||||
|
persistent: Persistent,
|
||||||
|
tab: Tab,
|
||||||
|
manager: Option<InjectionManager>,
|
||||||
|
manager_err: Option<String>,
|
||||||
|
watcher: ProcessWatcher,
|
||||||
|
processes: Vec<ProcInfo>,
|
||||||
|
last_refresh: Instant,
|
||||||
|
last_auto_inject_scan: Instant,
|
||||||
|
search: String,
|
||||||
|
rule_input: String,
|
||||||
|
rule_kind: PatternKind,
|
||||||
|
fake_date: NaiveDate,
|
||||||
|
fake_time: NaiveTime,
|
||||||
|
current_delta_ticks: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeMockerApp {
|
||||||
|
pub fn new(cc: &CreationContext<'_>) -> Self {
|
||||||
|
let persistent: Persistent = cc
|
||||||
|
.storage
|
||||||
|
.and_then(|s| eframe::get_value(s, "time_mocker"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let (manager, manager_err) = match InjectionManager::new() {
|
||||||
|
Ok(m) => (Some(m), None),
|
||||||
|
Err(e) => (None, Some(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut watcher = ProcessWatcher::new();
|
||||||
|
watcher.refresh();
|
||||||
|
let processes = watcher.list();
|
||||||
|
|
||||||
|
let now_local = Local::now();
|
||||||
|
Self {
|
||||||
|
persistent,
|
||||||
|
tab: Tab::Processes,
|
||||||
|
manager,
|
||||||
|
manager_err,
|
||||||
|
watcher,
|
||||||
|
processes,
|
||||||
|
last_refresh: Instant::now(),
|
||||||
|
last_auto_inject_scan: Instant::now(),
|
||||||
|
search: String::new(),
|
||||||
|
rule_input: String::new(),
|
||||||
|
rule_kind: PatternKind::Glob,
|
||||||
|
fake_date: now_local.date_naive(),
|
||||||
|
fake_time: now_local.time(),
|
||||||
|
current_delta_ticks: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_processes_if_due(&mut self) {
|
||||||
|
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()));
|
||||||
|
if let Some(m) = self.manager.as_mut() {
|
||||||
|
m.prune_dead(&self.watcher.alive_pids());
|
||||||
|
}
|
||||||
|
self.last_refresh = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auto_inject_scan_if_due(&mut self) {
|
||||||
|
if !self.persistent.auto_inject_enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.last_auto_inject_scan.elapsed() < Duration::from_millis(1500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.last_auto_inject_scan = Instant::now();
|
||||||
|
|
||||||
|
let compiled = CompiledRules::compile(&self.persistent.rules);
|
||||||
|
let Some(manager) = self.manager.as_mut() else { return };
|
||||||
|
|
||||||
|
for proc in &self.processes {
|
||||||
|
if manager.is_injected(proc.pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if compiled.matches(&proc.path, &proc.name) {
|
||||||
|
let _ = manager.inject(proc.pid, &proc.name, &proc.path, self.current_delta_ticks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_fake_time(&mut self) {
|
||||||
|
let naive = self.fake_date.and_time(self.fake_time);
|
||||||
|
let local: DateTime<Local> = match Local.from_local_datetime(&naive).single() {
|
||||||
|
Some(dt) => dt,
|
||||||
|
None => 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());
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_to_now(&mut self) {
|
||||||
|
let now = Local::now();
|
||||||
|
self.fake_date = now.date_naive();
|
||||||
|
self.fake_time = now.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.label("-");
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut m).desired_width(28.0));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.label(":");
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut mn).desired_width(28.0));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("Now").clicked() {
|
||||||
|
self.reset_to_now();
|
||||||
|
}
|
||||||
|
if ui.button("Set").clicked() {
|
||||||
|
self.apply_fake_time();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
let delta_secs = self.current_delta_ticks as f64 / 10_000_000.0;
|
||||||
|
ui.label(format!("Δ = {delta_secs:+.1}s"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_processes(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Search:");
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut self.search).desired_width(240.0));
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let manager_ready = self.manager.is_some();
|
||||||
|
if let Some(err) = &self.manager_err {
|
||||||
|
ui.colored_label(egui::Color32::RED, format!("InjectionManager unavailable: {err}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let needle = self.search.to_lowercase();
|
||||||
|
let rows: Vec<ProcInfo> = self
|
||||||
|
.processes
|
||||||
|
.iter()
|
||||||
|
.filter(|p| needle.is_empty() || p.name.to_lowercase().contains(&needle) || p.path.to_lowercase().contains(&needle))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
egui::Grid::new("processes")
|
||||||
|
.num_columns(4)
|
||||||
|
.striped(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.strong("Inject");
|
||||||
|
ui.strong("PID");
|
||||||
|
ui.strong("Name");
|
||||||
|
ui.strong("Path");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
for p in &rows {
|
||||||
|
let injected = self
|
||||||
|
.manager
|
||||||
|
.as_ref()
|
||||||
|
.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, ""));
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
m.eject(p.pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.label(p.pid.to_string());
|
||||||
|
ui.label(&p.name);
|
||||||
|
ui.label(&p.path);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_rules(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.persistent.auto_inject_enabled,
|
||||||
|
"Enable auto-inject watcher",
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Pattern:");
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut self.rule_input).desired_width(360.0));
|
||||||
|
egui::ComboBox::from_id_salt("rule_kind")
|
||||||
|
.selected_text(self.rule_kind.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.rule_kind, PatternKind::Glob, "Glob");
|
||||||
|
ui.selectable_value(&mut self.rule_kind, PatternKind::Regex, "Regex");
|
||||||
|
});
|
||||||
|
if ui.button("+ Add Rule").clicked() && !self.rule_input.trim().is_empty() {
|
||||||
|
self.persistent.rules.push(Rule {
|
||||||
|
pattern: self.rule_input.trim().to_owned(),
|
||||||
|
kind: self.rule_kind,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
self.rule_input.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let mut remove_idx: Option<usize> = None;
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
egui::Grid::new("rules")
|
||||||
|
.num_columns(4)
|
||||||
|
.striped(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.strong("On");
|
||||||
|
ui.strong("Kind");
|
||||||
|
ui.strong("Pattern");
|
||||||
|
ui.strong("");
|
||||||
|
ui.end_row();
|
||||||
|
for (i, rule) in self.persistent.rules.iter_mut().enumerate() {
|
||||||
|
ui.checkbox(&mut rule.enabled, "");
|
||||||
|
ui.label(rule.kind.label());
|
||||||
|
ui.label(&rule.pattern);
|
||||||
|
if ui.button("✕").clicked() {
|
||||||
|
remove_idx = Some(i);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Some(i) = remove_idx {
|
||||||
|
self.persistent.rules.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_log(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let Some(m) = self.manager.as_ref() else {
|
||||||
|
ui.label("InjectionManager unavailable.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.label(format!("Hook DLL: {}", m.hook_dll_path().display()));
|
||||||
|
ui.separator();
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.stick_to_bottom(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for line in &m.log {
|
||||||
|
ui.monospace(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for TimeMockerApp {
|
||||||
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
|
eframe::set_value(storage, "time_mocker", &self.persistent);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.refresh_processes_if_due();
|
||||||
|
self.auto_inject_scan_if_due();
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(500));
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("topbar").show(ctx, |ui| {
|
||||||
|
self.ui_top_bar(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(&mut self.tab, Tab::Processes, "Processes");
|
||||||
|
ui.selectable_value(&mut self.tab, Tab::Rules, "Auto-Inject Rules");
|
||||||
|
ui.selectable_value(&mut self.tab, Tab::Log, "Log");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| match self.tab {
|
||||||
|
Tab::Processes => self.ui_processes(ui),
|
||||||
|
Tab::Rules => self.ui_rules(ui),
|
||||||
|
Tab::Log => self.ui_log(ui),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn unix_micros_to_filetime_ticks(unix_micros: i64) -> i64 {
|
||||||
|
UNIX_TO_FILETIME_TICKS + unix_micros * 10
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Per-process injection state.
|
||||||
|
//!
|
||||||
|
//! 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)
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
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};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct InjectedProcess {
|
||||||
|
pub pid: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
delta: SharedDelta,
|
||||||
|
_syringe: Syringe,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InjectedProcess {
|
||||||
|
pub fn write_delta(&self, ticks: i64) {
|
||||||
|
self.delta.write_delta(ticks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InjectionManager {
|
||||||
|
injected: HashMap<u32, InjectedProcess>,
|
||||||
|
hook_dll_path: PathBuf,
|
||||||
|
pub log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InjectionManager {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let exe_dir = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.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");
|
||||||
|
Ok(Self {
|
||||||
|
injected: HashMap::new(),
|
||||||
|
hook_dll_path,
|
||||||
|
log: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hook_dll_path(&self) -> &Path {
|
||||||
|
&self.hook_dll_path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_injected(&self, pid: u32) -> bool {
|
||||||
|
self.injected.contains_key(&pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &InjectedProcess> {
|
||||||
|
self.injected.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inject(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
|
||||||
|
if self.injected.contains_key(&pid) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !self.hook_dll_path.exists() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"hook DLL not found at {}",
|
||||||
|
self.hook_dll_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mmf_name = mmf_name_for_pid(pid);
|
||||||
|
let delta = SharedDelta::create(&mmf_name)
|
||||||
|
.with_context(|| format!("create MMF {mmf_name}"))?;
|
||||||
|
delta.write_delta(initial_delta);
|
||||||
|
|
||||||
|
let process = OwnedProcess::from_pid(pid)
|
||||||
|
.with_context(|| format!("open process pid={pid}"))?;
|
||||||
|
let syringe = Syringe::for_process(process);
|
||||||
|
syringe
|
||||||
|
.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.injected.insert(
|
||||||
|
pid,
|
||||||
|
InjectedProcess {
|
||||||
|
pid,
|
||||||
|
name: name.to_owned(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
delta,
|
||||||
|
_syringe: syringe,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_delta_all(&self, ticks: i64) {
|
||||||
|
for proc in self.injected.values() {
|
||||||
|
proc.write_delta(ticks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn eject(&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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop entries for processes that have exited.
|
||||||
|
pub fn prune_dead(&mut self, alive: &std::collections::HashSet<u32>) {
|
||||||
|
let dead: Vec<u32> = self
|
||||||
|
.injected
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|pid| !alive.contains(pid))
|
||||||
|
.collect();
|
||||||
|
for pid in dead {
|
||||||
|
self.injected.remove(&pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Controller UI for time-mocker.
|
||||||
|
//!
|
||||||
|
//! Modules:
|
||||||
|
//! - `injection_manager`: dll-syringe-backed injector + per-PID `SharedDelta`
|
||||||
|
//! - `process_watcher`: poll-based auto-inject scanner
|
||||||
|
//! - `rules`: glob / regex pattern matcher
|
||||||
|
//! - `app`: eframe `App` impl, tabs, persistent settings
|
||||||
|
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod injection_manager;
|
||||||
|
mod process_watcher;
|
||||||
|
mod rules;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([900.0, 600.0])
|
||||||
|
.with_min_inner_size([640.0, 400.0])
|
||||||
|
.with_title("TimeMocker"),
|
||||||
|
persist_window: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"TimeMocker",
|
||||||
|
native_options,
|
||||||
|
Box::new(|cc| Ok(Box::new(app::TimeMockerApp::new(cc)))),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("eframe error: {e}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Lightweight wrapper around `sysinfo` for the process tab + auto-inject scan.
|
||||||
|
//!
|
||||||
|
//! Pure data — no UI, no injection. The `App` polls `refresh()` and decides
|
||||||
|
//! what to inject based on `CompiledRules`.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use sysinfo::System;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProcInfo {
|
||||||
|
pub pid: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProcessWatcher {
|
||||||
|
sys: System,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessWatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { sys: System::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
self.sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Vec<ProcInfo> {
|
||||||
|
self.sys
|
||||||
|
.processes()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(pid, proc)| {
|
||||||
|
let name = proc.name().to_string_lossy().into_owned();
|
||||||
|
let path = proc
|
||||||
|
.exe()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ProcInfo {
|
||||||
|
pid: pid.as_u32(),
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alive_pids(&self) -> HashSet<u32> {
|
||||||
|
self.sys.processes().keys().map(|p| p.as_u32()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Auto-inject pattern rules — glob or regex matched against process path and name.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use globset::{Glob, GlobMatcher};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PatternKind {
|
||||||
|
Glob,
|
||||||
|
Regex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatternKind {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PatternKind::Glob => "Glob",
|
||||||
|
PatternKind::Regex => "Regex",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Rule {
|
||||||
|
pub pattern: String,
|
||||||
|
pub kind: PatternKind,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CompiledRules {
|
||||||
|
matchers: Vec<Matcher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Matcher {
|
||||||
|
Glob(GlobMatcher),
|
||||||
|
Regex(Regex),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Matcher {
|
||||||
|
fn is_match(&self, s: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Matcher::Glob(g) => g.is_match(s),
|
||||||
|
Matcher::Regex(r) => r.is_match(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompiledRules {
|
||||||
|
pub fn compile(rules: &[Rule]) -> Self {
|
||||||
|
let matchers = rules
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.enabled)
|
||||||
|
.filter_map(|r| compile_one(r).ok())
|
||||||
|
.collect();
|
||||||
|
Self { matchers }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches(&self, path: &str, name: &str) -> bool {
|
||||||
|
self.matchers
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.is_match(path) || m.is_match(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_one(rule: &Rule) -> Result<Matcher> {
|
||||||
|
match rule.kind {
|
||||||
|
PatternKind::Glob => {
|
||||||
|
let g = Glob::new(&rule.pattern).map_err(|e| anyhow!("glob: {e}"))?;
|
||||||
|
Ok(Matcher::Glob(g.compile_matcher()))
|
||||||
|
}
|
||||||
|
PatternKind::Regex => Ok(Matcher::Regex(
|
||||||
|
Regex::new(&rule.pattern).map_err(|e| anyhow!("regex: {e}"))?,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_matches_name() {
|
||||||
|
let rules = vec![Rule {
|
||||||
|
pattern: "*chrome*".into(),
|
||||||
|
kind: PatternKind::Glob,
|
||||||
|
enabled: true,
|
||||||
|
}];
|
||||||
|
let c = CompiledRules::compile(&rules);
|
||||||
|
assert!(c.matches("", "chrome.exe"));
|
||||||
|
assert!(!c.matches("", "firefox.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_matches_path() {
|
||||||
|
let rules = vec![Rule {
|
||||||
|
pattern: r"^.*\\MyApp\.exe$".into(),
|
||||||
|
kind: PatternKind::Regex,
|
||||||
|
enabled: true,
|
||||||
|
}];
|
||||||
|
let c = CompiledRules::compile(&rules);
|
||||||
|
assert!(c.matches(r"C:\foo\MyApp.exe", "MyApp.exe"));
|
||||||
|
assert!(!c.matches(r"C:\foo\Other.exe", "Other.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_rules_skipped() {
|
||||||
|
let rules = vec![Rule {
|
||||||
|
pattern: "*".into(),
|
||||||
|
kind: PatternKind::Glob,
|
||||||
|
enabled: false,
|
||||||
|
}];
|
||||||
|
let c = CompiledRules::compile(&rules);
|
||||||
|
assert!(!c.matches("anything", "anything"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
profile = "minimal"
|
||||||
Reference in new issue
Block a user