mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-10-11 20:14:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da4af498b | ||
|
|
ffab962360 | ||
|
|
f4bd35ae5b | ||
|
|
a2e34060e0 | ||
|
|
78edb78649 | ||
|
|
e863efc98f | ||
|
|
5bb4a68082 | ||
|
|
79d3b69dcd | ||
|
|
438d11fde7 | ||
|
|
461298b453 | ||
|
|
52a01be2e4 | ||
|
|
4d3c35a126 | ||
|
|
b93c17d2bb | ||
|
|
001cf53c37 | ||
|
|
581785d90e | ||
|
|
de34a948f0 | ||
|
|
e6b31272a6 | ||
|
|
194a1179bb | ||
|
|
695bfde6f2 | ||
|
|
f4f8baa1e6 | ||
|
|
a6b5eb7606 |
No files matched your search
@@ -0,0 +1 @@
|
||||
- [History rewrite verification](feedback_history-rewrite-verification.md) — raw-byte message compare, empty-tree fsck false positive, merge commits in "linear" chains
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: history-rewrite-verification
|
||||
description: Gotchas when verifying content-replacement git history rewrites before a force push (raw message bytes, virtual empty tree, backup ref)
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When verifying a replayed/rewritten git history before an irreversible force push:
|
||||
|
||||
- Compare commit messages as RAW BYTES from `git cat-file commit <sha>` (slice after the first blank line by byte offset). `--format=%B` appends a trailing newline and will mask or fabricate a diff. A prior build in this project was rejected for exactly this.
|
||||
- `git fsck` reporting `missing tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904` (the empty tree) is a FALSE POSITIVE. Git special-cases the empty tree, so it is absent from the object DB yet `cat-file -e` succeeds and `pack-objects` still builds a valid pack. Prove push viability with `pack-objects --stdout --revs --thin` rather than trusting fsck.
|
||||
- Do not call a chain "linear" from commit count alone. Check `rev-list --min-parents=2`; a preserved segment can legitimately carry an inherited merge commit.
|
||||
|
||||
**Why:** these three checks each produce a confident-looking pass/fail that is wrong, and the force push is irreversible.
|
||||
|
||||
**How to apply:** on any repo-merge / history-rewrite review. Also confirm the remote backup ref exists via `git ls-remote --heads` BEFORE approving, since it is what keeps the orphaned SHAs alive on GitHub.
|
||||
|
||||
Related: [[repo-merge-blast-radius-checks]]
|
||||
@@ -0,0 +1,27 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: fmt-clippy-test-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain (pinned by rust-toolchain.toml)
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- run: cargo test --workspace
|
||||
@@ -0,0 +1,88 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag name (e.g. v0.1.0) — required when running manually"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain (pinned by rust-toolchain.toml)
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo registry + target
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: cargo test --workspace
|
||||
run: cargo test --workspace --release
|
||||
|
||||
- name: cargo clippy --workspace --all-targets -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: cargo build --workspace --release
|
||||
run: cargo build --workspace --release
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Stage release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
cp target/release/time_mocker_ui.exe release-staging/
|
||||
cp target/release/time_mocker_hook.dll release-staging/
|
||||
cp LICENSE release-staging/
|
||||
cp README.md release-staging/
|
||||
|
||||
- name: Pack zip
|
||||
id: pack
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ steps.tag.outputs.tag }}"
|
||||
$zip = "time-mocker-$tag-windows-x64.zip"
|
||||
Compress-Archive -Path release-staging\* -DestinationPath $zip -Force
|
||||
(Get-FileHash $zip -Algorithm SHA256).Hash | Out-File "$zip.sha256" -Encoding ascii
|
||||
"zip=$zip" >> $env:GITHUB_OUTPUT
|
||||
"sha=$zip.sha256" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload artifacts (workflow run)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: time-mocker-windows-x64
|
||||
path: |
|
||||
${{ steps.pack.outputs.zip }}
|
||||
${{ steps.pack.outputs.sha }}
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: time-mocker ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
${{ steps.pack.outputs.zip }}
|
||||
${{ steps.pack.outputs.sha }}
|
||||
+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 (stateful working notes, not shipped docs)
|
||||
/plans/
|
||||
Generated
+3472
File diff suppressed because it is too large.
Load diff
+31
@@ -0,0 +1,31 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/time-mocker-core",
|
||||
"crates/time-mocker-hook",
|
||||
"crates/time-mocker-test-target",
|
||||
"crates/time-mocker-ui",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["tiennm99"]
|
||||
repository = "https://github.com/tiennm99/time-mocker"
|
||||
# MSRV is informational: rust-toolchain.toml pins nightly, which retour needs for
|
||||
# inline x64 detours. Stable rustc is not supported (see README § Requirements).
|
||||
rust-version = "1.90"
|
||||
|
||||
[workspace.dependencies]
|
||||
windows-sys = "0.59"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
+12
-12
@@ -137,8 +137,8 @@
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
@@ -163,15 +163,15 @@
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
the Work or Derivative Works thereof, You may accept and charge a
|
||||
fee for, acceptance of support, warranty, indemnity, or other
|
||||
liability obligations and/or rights consistent with this License.
|
||||
However, in accepting such obligations, You may act only on Your
|
||||
own behalf and on Your sole responsibility, not on behalf of any
|
||||
other Contributor, and only if You agree to indemnify, defend,
|
||||
and hold each Contributor harmless for any liability incurred by,
|
||||
or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2026 tiennm99
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -0,0 +1,93 @@
|
||||
# TimeMocker
|
||||
|
||||
A Windows tool that injects fake time into running processes by hooking Win32 time APIs. Written in Rust.
|
||||
|
||||
> **Status:** Active development. This is the canonical implementation. Earlier C# (EasyHook) and C++ (MS Detours) ports are preserved in this repo's git history under `csharp/` and `cpp/` (removed from the working tree — their IPC tick epoch is incompatible with this implementation, see below).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
time-mocker/
|
||||
├── 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-test-target/ — console binary that prints all 5 hooked APIs in a loop (dedicated dev target)
|
||||
│ └── 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: Global\TimeMocker_<PID> (preferred — requires elevation)
|
||||
Local\TimeMocker_<PID> (fallback — same-session, unelevated dev)
|
||||
Size: 8 bytes
|
||||
[0..7] DeltaTicks (i64 — 100-ns units, added to the real FILETIME)
|
||||
```
|
||||
|
||||
The UI tries `Global\` first and falls back to `Local\` on `ERROR_ACCESS_DENIED`
|
||||
(i.e. when the controller is not elevated and the token lacks
|
||||
`SeCreateGlobalPrivilege`). The hook DLL probes both names. Release builds
|
||||
embed a UAC manifest, so they always get `Global\`; debug builds run
|
||||
unelevated and use `Local\` for same-session targets.
|
||||
|
||||
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`)
|
||||
- Release build: must run as Administrator (UAC manifest embedded; needed for `Global\` MMF and cross-session injection)
|
||||
- Debug build (`cargo run`): runs unelevated; falls back to `Local\` namespace — same-session targets only
|
||||
|
||||
## Dev workflow: test target
|
||||
|
||||
Inject into a dedicated harness instead of arbitrary running processes:
|
||||
|
||||
```powershell
|
||||
# Terminal 1 — start the target, note the PID it prints
|
||||
cargo run -p time-mocker-test-target
|
||||
|
||||
# Terminal 2 — start the UI; type that PID into "Inject by PID"
|
||||
cargo build --workspace ; cargo run -p time-mocker-ui
|
||||
```
|
||||
|
||||
The target prints all 5 hooked APIs (`GetSystemTime`, `GetLocalTime`,
|
||||
`GetSystemTimeAsFileTime`, `GetSystemTimePreciseAsFileTime`,
|
||||
`NtQuerySystemTime`) every second. When the hook is loaded and you set a
|
||||
fake time in the UI, all five rows shift by the same delta — that's your
|
||||
proof the hook is live.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](LICENSE).
|
||||
|
||||
## Related
|
||||
|
||||
- `csharp/`, `cpp/` — earlier C# (EasyHook) and C++ (MS Detours) ports, preserved in git history only: `git log -- csharp/ cpp/`
|
||||
- [`java-target/`](java-target/) — Java (Gradle) harness that prints the JVM clock every second, for observing the fake time from a managed runtime
|
||||
@@ -0,0 +1,21 @@
|
||||
[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",
|
||||
] }
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Shared types and named MMF helper for time-mocker.
|
||||
//!
|
||||
//! IPC contract: an 8-byte memory-mapped file named `Global\TimeMocker_<pid>`
|
||||
//! (or `Local\TimeMocker_<pid>` as a session-scoped fallback) 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, but creating an
|
||||
//! object there requires `SeCreateGlobalPrivilege` — granted only to elevated
|
||||
//! tokens. Debug / non-elevated controllers fall back to `Local\` so dev
|
||||
//! workflows can still hook same-session targets.
|
||||
//!
|
||||
//! Both endpoints (writer = UI, reader = hook DLL) try `Global\` first and
|
||||
//! then `Local\`, so a controller's elevation state determines the namespace
|
||||
//! used and the hook just probes both.
|
||||
|
||||
// `mmf` wraps Win32-only APIs (named file mappings), so it is gated on
|
||||
// `windows` at the module level. `ticks` and `types` hold pure arithmetic —
|
||||
// no Win32 dependency at all beyond the FILETIME/SYSTEMTIME conversions
|
||||
// inside `ticks`, which are individually `#[cfg(windows)]` — so both stay
|
||||
// buildable and testable on any host (e.g. `cargo test -p time-mocker-core`
|
||||
// on Linux CI/dev boxes actually exercises the tick-arithmetic tests instead
|
||||
// of compiling an empty crate).
|
||||
#[cfg(windows)]
|
||||
pub mod mmf;
|
||||
pub mod ticks;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use mmf::{CreateOutcome, SharedDeltaReader, SharedDeltaWriter};
|
||||
pub use types::MockTimeInfo;
|
||||
|
||||
pub const MMF_PREFIX_GLOBAL: &str = "Global\\TimeMocker_";
|
||||
pub const MMF_PREFIX_LOCAL: &str = "Local\\TimeMocker_";
|
||||
|
||||
/// Back-compat alias: the primary (elevated) namespace.
|
||||
pub const MMF_PREFIX: &str = MMF_PREFIX_GLOBAL;
|
||||
|
||||
#[inline]
|
||||
pub fn mmf_name_for_pid(pid: u32) -> String {
|
||||
format!("{MMF_PREFIX_GLOBAL}{pid}")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn local_mmf_name_for_pid(pid: u32) -> String {
|
||||
format!("{MMF_PREFIX_LOCAL}{pid}")
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Named memory-mapped file wrapper around the 8-byte `MockTimeInfo` payload.
|
||||
//!
|
||||
//! 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;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::types::MockTimeInfo;
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
|
||||
impl MappingHandle {
|
||||
#[inline]
|
||||
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 MappingHandle {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
// NOTE: NULL security descriptor -> default DACL (creator +
|
||||
// SYSTEM/Administrators). Fine for same-user debug targets;
|
||||
// a target running as a different user/service identity than
|
||||
// the controller will fail to open this mapping and silently
|
||||
// get no hooks installed. Accepted for now: this is a local
|
||||
// debugging tool, not a service-hardening one.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! `apply_delta` and the `*_VALID_TICKS` bounds are plain `i64` arithmetic
|
||||
//! with no Win32 dependency, so they build and run on any host. The
|
||||
//! FILETIME/SYSTEMTIME conversions below them call into `windows_sys` and are
|
||||
//! `#[cfg(windows)]`.
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToFileTime};
|
||||
|
||||
/// Lowest FILETIME tick value `FileTimeToSystemTime` accepts. FILETIME is
|
||||
/// defined as an unsigned 64-bit tick count from the 1601-01-01 epoch, so a
|
||||
/// negative `i64` (sign bit set) is never a valid FILETIME.
|
||||
pub const MIN_VALID_TICKS: i64 = 0;
|
||||
|
||||
/// Highest FILETIME tick value `FileTimeToSystemTime` accepts: 30827-12-31
|
||||
/// 23:59:59.9999999 UTC, the last instant representable in a `SYSTEMTIME`
|
||||
/// (`wYear` is a `u16`, and Win32 defines this as the ceiling). Any FILETIME
|
||||
/// beyond this fails conversion.
|
||||
pub const MAX_VALID_TICKS: i64 = 0x7FFF_35F4_F06C_58F0;
|
||||
|
||||
/// Add `delta` to `real_ticks` and clamp to the range `FileTimeToSystemTime`
|
||||
/// can convert. A saturating add alone stops at `i64::MIN`/`i64::MAX`, but
|
||||
/// those are far outside the valid FILETIME range: the delta comes from
|
||||
/// shared memory written by a separate, possibly stale controller process,
|
||||
/// so it must not be trusted to keep the result in range. Clamping here means
|
||||
/// every caller downstream gets a value that round-trips through
|
||||
/// `ticks_to_systemtime` instead of failing conversion with an untouched
|
||||
/// output buffer.
|
||||
#[inline]
|
||||
pub fn apply_delta(real_ticks: i64, delta: i64) -> i64 {
|
||||
real_ticks
|
||||
.saturating_add(delta)
|
||||
.clamp(MIN_VALID_TICKS, MAX_VALID_TICKS)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
pub fn filetime_to_i64(ft: FILETIME) -> i64 {
|
||||
((ft.dwHighDateTime as i64) << 32) | (ft.dwLowDateTime as i64 & 0xFFFF_FFFF)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[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.
|
||||
#[cfg(windows)]
|
||||
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.
|
||||
#[cfg(windows)]
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_delta_zero_is_identity() {
|
||||
assert_eq!(apply_delta(1_000, 0), 1_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_positive_offset() {
|
||||
assert_eq!(apply_delta(1_000, 500), 1_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_negative_offset() {
|
||||
assert_eq!(apply_delta(1_000, -500), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_saturates_instead_of_overflowing() {
|
||||
// i64::MAX + a positive delta would overflow a plain `+`; saturating_add
|
||||
// (and then the clamp below) must not panic or wrap.
|
||||
assert_eq!(apply_delta(i64::MAX, i64::MAX), MAX_VALID_TICKS);
|
||||
assert_eq!(apply_delta(i64::MIN, i64::MIN), MIN_VALID_TICKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_clamps_below_epoch_to_min_valid() {
|
||||
// A large negative delta applied to an early real time would produce a
|
||||
// negative tick count — not representable as FILETIME (unsigned).
|
||||
assert_eq!(apply_delta(100, -1_000), MIN_VALID_TICKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_clamps_above_ceiling_to_max_valid() {
|
||||
assert_eq!(apply_delta(MAX_VALID_TICKS, 1), MAX_VALID_TICKS);
|
||||
assert_eq!(
|
||||
apply_delta(MAX_VALID_TICKS - 10, 1_000_000),
|
||||
MAX_VALID_TICKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_respects_exact_clamp_boundaries() {
|
||||
// One tick inside either boundary must pass through unchanged.
|
||||
assert_eq!(apply_delta(MIN_VALID_TICKS + 1, 0), MIN_VALID_TICKS + 1);
|
||||
assert_eq!(apply_delta(MAX_VALID_TICKS - 1, 0), MAX_VALID_TICKS - 1);
|
||||
assert_eq!(apply_delta(MIN_VALID_TICKS, 0), MIN_VALID_TICKS);
|
||||
assert_eq!(apply_delta(MAX_VALID_TICKS, 0), MAX_VALID_TICKS);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn filetime_i64_roundtrip() {
|
||||
// Covers: zero, small, Unix epoch (1970 in FILETIME ticks), a recent
|
||||
// time, and the i64 boundary. `filetime_to_i64`/`i64_to_filetime` are
|
||||
// plain bit-packing and round-trip the full i64 range (including
|
||||
// negative/out-of-FILETIME-range values) even though those values are
|
||||
// never handed to Win32 conversion APIs — `apply_delta` above is what
|
||||
// keeps callers in the valid range before that happens.
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn ticks_to_systemtime_rejects_out_of_range_values() {
|
||||
assert!(ticks_to_systemtime(MAX_VALID_TICKS + 1).is_none());
|
||||
assert!(ticks_to_systemtime(MIN_VALID_TICKS - 1).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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,29 @@
|
||||
[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" }
|
||||
# Pre-release range: no stable retour release ships static-detour inline x64 hooking yet.
|
||||
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",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Time",
|
||||
] }
|
||||
@@ -0,0 +1,121 @@
|
||||
//! `DllMain` and worker-thread bootstrap.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! Load-once, never-unload invariant: `DllMain` pins this module (see
|
||||
//! `pin_module`) and there is no `DLL_PROCESS_DETACH` handler. Once the
|
||||
//! detours are enabled, kernel32/ntdll contain jumps into this DLL's
|
||||
//! trampoline pages; unhooking them would require draining every thread that
|
||||
//! might currently be inside a trampoline, which cannot be done safely from
|
||||
//! `DLL_PROCESS_DETACH` under the loader lock. Pinning means the loader can
|
||||
//! never unmap us out from under those jumps, even if something (a future
|
||||
//! `eject` path, or a stray `FreeLibrary`) tries.
|
||||
|
||||
use std::ffi::{c_void, OsStr};
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
|
||||
use time_mocker_core::{local_mmf_name_for_pid, mmf_name_for_pid};
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HMODULE, TRUE};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW;
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
DisableThreadLibraryCalls, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
||||
GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
};
|
||||
use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
||||
use windows_sys::Win32::System::Threading::{CreateThread, GetCurrentProcessId};
|
||||
|
||||
use crate::hooks::{self, InstallReport};
|
||||
|
||||
#[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 {
|
||||
DisableThreadLibraryCalls(hinst);
|
||||
pin_module();
|
||||
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
|
||||
}
|
||||
|
||||
/// Bump this module's loader reference count so it can never be unmapped
|
||||
/// (see the module-doc invariant above). `GET_MODULE_HANDLE_EX_FLAG_PIN`
|
||||
/// combined with `..._FROM_ADDRESS` resolves the target module from an
|
||||
/// address inside it — `DllMain`'s own address — so this needs no file path
|
||||
/// or module name. Best-effort: if it ever fails, we still proceed with
|
||||
/// install rather than abort the injection, since a pin failure here is far
|
||||
/// less likely than the process simply never unloading us in practice.
|
||||
unsafe fn pin_module() {
|
||||
let mut pinned: HMODULE = ptr::null_mut();
|
||||
GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
||||
DllMain as *const () as usize as *const u16,
|
||||
&mut pinned,
|
||||
);
|
||||
}
|
||||
|
||||
unsafe extern "system" fn bootstrap_thread(_param: *mut c_void) -> u32 {
|
||||
bootstrap();
|
||||
0
|
||||
}
|
||||
|
||||
fn bootstrap() {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let global = mmf_name_for_pid(pid);
|
||||
let local = local_mmf_name_for_pid(pid);
|
||||
|
||||
// Install the hooks unconditionally — do not gate installation on the
|
||||
// delta MMF already existing. The normal workflow order is "inject, then
|
||||
// open the delta channel", so at this point in a fresh injection neither
|
||||
// name may exist yet; `hooks::install` connects to whichever one shows up
|
||||
// lazily (with retry) on first use inside the hot path. Gating install on
|
||||
// a successful open here would leave the hooks permanently uninstalled
|
||||
// for the rest of the process's life whenever the controller sets the
|
||||
// delta after injection completes.
|
||||
let report = hooks::install(global, local);
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
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()) };
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Inline detours for 5 Win32 time APIs.
|
||||
//!
|
||||
//! 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 std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use time_mocker_core::ticks::{apply_delta, 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, SystemTimeToTzSpecificLocalTime};
|
||||
|
||||
/// Re-attempt opening the delta MMF once every this many `delta()` calls while
|
||||
/// disconnected. The normal workflow order is inject-then-open-channel, so the
|
||||
/// first `bootstrap()` probe commonly runs before the controller has created
|
||||
/// the mapping; without a retry the process would carry no-op hooks for its
|
||||
/// entire lifetime. A call-count backoff (instead of a wall-clock timer and
|
||||
/// its own thread) keeps the hot path — the already-connected case — down to
|
||||
/// one relaxed atomic read, and costs nothing extra to implement.
|
||||
const RETRY_EVERY_N_CALLS: u32 = 4096;
|
||||
|
||||
/// Names to probe plus the lazily-connected reader. `misses_since_retry`
|
||||
/// drives the backoff in `try_reconnect`.
|
||||
struct DeltaSource {
|
||||
global_name: String,
|
||||
local_name: String,
|
||||
reader: RwLock<Option<SharedDeltaReader>>,
|
||||
misses_since_retry: AtomicU32,
|
||||
}
|
||||
|
||||
static SOURCE: OnceCell<DeltaSource> = 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;
|
||||
|
||||
/// 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 a single detour (phase 1 of `install`). Pushes to
|
||||
/// `failed` (with reason) and yields `false` on any failure instead of
|
||||
/// aborting the wider install; yields `true` when the detour is ready to be
|
||||
/// enabled in phase 2.
|
||||
macro_rules! init_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()));
|
||||
false
|
||||
}
|
||||
Some(target) => match unsafe { $detour.initialize(target, $callback) } {
|
||||
Err(e) => {
|
||||
$report.failed.push(($proc, format!("initialize: {e}")));
|
||||
false
|
||||
}
|
||||
Ok(_) => true,
|
||||
},
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// Enable a detour that was successfully initialized in phase 1 (phase 2 of
|
||||
/// `install`). `unmet_dependency`, when `Some`, means a *different* detour
|
||||
/// this hook body calls through (e.g. `hook_get_system_time` calls
|
||||
/// `GetSystemTimeAsFileTimeDetour`) failed to initialize — enabling this hook
|
||||
/// anyway would arm a hook body that panics the first time it runs, so it is
|
||||
/// recorded as failed instead.
|
||||
fn try_enable<T: retour::Function>(
|
||||
report: &mut InstallReport,
|
||||
detour: &'static retour::StaticDetour<T>,
|
||||
name: &'static str,
|
||||
initialized: bool,
|
||||
unmet_dependency: Option<&'static str>,
|
||||
) {
|
||||
if !initialized {
|
||||
return; // already recorded as failed by init_hook!
|
||||
}
|
||||
if let Some(dep) = unmet_dependency {
|
||||
report
|
||||
.failed
|
||||
.push((name, format!("dependency {dep} did not initialize")));
|
||||
return;
|
||||
}
|
||||
match unsafe { detour.enable() } {
|
||||
Err(e) => report.failed.push((name, format!("enable: {e}"))),
|
||||
Ok(()) => report.installed.push(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve, initialize, and enable every hook, then wire up the (lazily
|
||||
/// connected) delta source. `global_name`/`local_name` are the two MMF names
|
||||
/// the bootstrap thread probes — connecting happens on first use inside
|
||||
/// `delta()`, not here, so hooking still activates even if the controller
|
||||
/// creates the mapping after injection has already completed.
|
||||
pub fn install(global_name: String, local_name: String) -> InstallReport {
|
||||
let _ = SOURCE.set(DeltaSource {
|
||||
global_name,
|
||||
local_name,
|
||||
reader: RwLock::new(None),
|
||||
misses_since_retry: AtomicU32::new(0),
|
||||
});
|
||||
|
||||
let mut report = InstallReport::default();
|
||||
|
||||
// Phase 1: resolve + initialize every detour before enabling any of them.
|
||||
// `hook_get_system_time` and `hook_get_local_time` call through
|
||||
// `GetSystemTimeAsFileTimeDetour`'s trampoline, and the trampoline only
|
||||
// exists once that detour has been initialized — `static_detour!`'s
|
||||
// generated `call()` panics with `NotInitialized` otherwise, and with
|
||||
// `panic = "abort"` in the release profile that panic kills the target
|
||||
// process. Initializing every detour before enabling any of them removes
|
||||
// the racy window (GetSystemTime enabled while another thread is still
|
||||
// initializing the FileTime detour); phase 2 below additionally refuses
|
||||
// to enable a hook whose dependency never initialized at all, which
|
||||
// removes the permanent version of the same failure.
|
||||
let system_time_ok = init_hook!(
|
||||
report,
|
||||
GetSystemTimeDetour,
|
||||
"kernel32.dll",
|
||||
"GetSystemTime",
|
||||
FnSystemTime,
|
||||
hook_get_system_time
|
||||
);
|
||||
let local_time_ok = init_hook!(
|
||||
report,
|
||||
GetLocalTimeDetour,
|
||||
"kernel32.dll",
|
||||
"GetLocalTime",
|
||||
FnSystemTime,
|
||||
hook_get_local_time
|
||||
);
|
||||
let filetime_ok = init_hook!(
|
||||
report,
|
||||
GetSystemTimeAsFileTimeDetour,
|
||||
"kernel32.dll",
|
||||
"GetSystemTimeAsFileTime",
|
||||
FnFileTime,
|
||||
hook_get_system_time_as_filetime
|
||||
);
|
||||
let precise_filetime_ok = init_hook!(
|
||||
report,
|
||||
GetSystemTimePreciseAsFileTimeDetour,
|
||||
"kernel32.dll",
|
||||
"GetSystemTimePreciseAsFileTime",
|
||||
FnFileTime,
|
||||
hook_get_system_time_precise_as_filetime
|
||||
);
|
||||
let nt_query_ok = init_hook!(
|
||||
report,
|
||||
NtQuerySystemTimeDetour,
|
||||
"ntdll.dll",
|
||||
"NtQuerySystemTime",
|
||||
FnNtQuerySystemTime,
|
||||
hook_nt_query_system_time
|
||||
);
|
||||
|
||||
// Phase 2: enable. Retour patches the live prologue with no thread
|
||||
// suspension or i-cache flush of its own (verified in the vendored
|
||||
// source); a thread executing that exact prologue mid-`enable()` is a
|
||||
// known, accepted race inherent to this hooking approach, not something
|
||||
// this crate mitigates.
|
||||
let unmet_filetime = (!filetime_ok).then_some("GetSystemTimeAsFileTime");
|
||||
try_enable(
|
||||
&mut report,
|
||||
&GetSystemTimeDetour,
|
||||
"GetSystemTime",
|
||||
system_time_ok,
|
||||
unmet_filetime,
|
||||
);
|
||||
try_enable(
|
||||
&mut report,
|
||||
&GetLocalTimeDetour,
|
||||
"GetLocalTime",
|
||||
local_time_ok,
|
||||
unmet_filetime,
|
||||
);
|
||||
try_enable(
|
||||
&mut report,
|
||||
&GetSystemTimeAsFileTimeDetour,
|
||||
"GetSystemTimeAsFileTime",
|
||||
filetime_ok,
|
||||
None,
|
||||
);
|
||||
try_enable(
|
||||
&mut report,
|
||||
&GetSystemTimePreciseAsFileTimeDetour,
|
||||
"GetSystemTimePreciseAsFileTime",
|
||||
precise_filetime_ok,
|
||||
None,
|
||||
);
|
||||
try_enable(
|
||||
&mut report,
|
||||
&NtQuerySystemTimeDetour,
|
||||
"NtQuerySystemTime",
|
||||
nt_query_ok,
|
||||
None,
|
||||
);
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
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 addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?;
|
||||
Some(std::mem::transmute_copy::<_, F>(&addr))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn delta() -> i64 {
|
||||
let Some(source) = SOURCE.get() else {
|
||||
return 0;
|
||||
};
|
||||
if let Ok(guard) = source.reader.read() {
|
||||
if let Some(reader) = guard.as_ref() {
|
||||
return reader.read_delta();
|
||||
}
|
||||
}
|
||||
try_reconnect(source)
|
||||
}
|
||||
|
||||
/// Not yet connected to the delta channel. Re-probe both MMF names once every
|
||||
/// `RETRY_EVERY_N_CALLS` misses (see its doc comment) instead of on every
|
||||
/// call. A race between threads landing on the same retry slot is harmless —
|
||||
/// `SharedDeltaReader::open` is idempotent and cheap to call twice.
|
||||
fn try_reconnect(source: &DeltaSource) -> i64 {
|
||||
let misses = source.misses_since_retry.fetch_add(1, Ordering::Relaxed);
|
||||
if !misses.is_multiple_of(RETRY_EVERY_N_CALLS) {
|
||||
return 0;
|
||||
}
|
||||
let opened = SharedDeltaReader::open(&source.global_name)
|
||||
.or_else(|_| SharedDeltaReader::open(&source.local_name));
|
||||
match opened {
|
||||
Ok(reader) => {
|
||||
let value = reader.read_delta();
|
||||
if let Ok(mut guard) = source.reader.write() {
|
||||
*guard = Some(reader);
|
||||
}
|
||||
value
|
||||
}
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke the supplied trampoline to get the real FILETIME, then add the
|
||||
/// shared delta, clamped to the range `FileTimeToSystemTime` can convert (see
|
||||
/// `time_mocker_core::ticks::apply_delta`) so callers never see a conversion
|
||||
/// failure caused by an out-of-range delta from shared memory.
|
||||
#[inline]
|
||||
fn fake_filetime(call_real: impl FnOnce(*mut FILETIME)) -> FILETIME {
|
||||
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
|
||||
call_real(&mut ft);
|
||||
let ticks = apply_delta(filetime_to_i64(ft), delta());
|
||||
i64_to_filetime(ticks)
|
||||
}
|
||||
|
||||
fn hook_get_system_time(out: *mut SYSTEMTIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
let ft = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) });
|
||||
if unsafe { FileTimeToSystemTime(&ft, out) } == 0 {
|
||||
// `apply_delta` keeps `ft` in the valid FILETIME range, so this should
|
||||
// not happen — but the delta is attacker/bug-controlled input from a
|
||||
// separate process, so never leave `out` uninitialized on the
|
||||
// off-chance it does. `GetSystemTimeDetour` is exactly the detour
|
||||
// currently executing this hook body, so its trampoline is guaranteed
|
||||
// initialized here.
|
||||
unsafe { GetSystemTimeDetour.call(out) };
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_get_local_time(out: *mut SYSTEMTIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
// 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 {
|
||||
// See `hook_get_system_time` — fall back to the real local time rather
|
||||
// than return with `out` uninitialized. `GetLocalTimeDetour` is this
|
||||
// hook's own detour, so its trampoline is guaranteed initialized here.
|
||||
unsafe { GetLocalTimeDetour.call(out) };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_get_system_time_as_filetime(out: *mut FILETIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 STATUS_ACCESS_VIOLATION;
|
||||
}
|
||||
let mut real: i64 = 0;
|
||||
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 = apply_delta(real, 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,22 @@
|
||||
[package]
|
||||
name = "time-mocker-test-target"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Dedicated injection target — prints all 5 hooked time APIs in a loop so you can verify the hook DLL without touching arbitrary running processes"
|
||||
|
||||
[[bin]]
|
||||
name = "time_mocker_test_target"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Time",
|
||||
] }
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Dedicated injection target for time-mocker.
|
||||
//!
|
||||
//! Prints its own PID at startup, then loops printing the five hooked time
|
||||
//! APIs side-by-side every second. Lets you verify the hook DLL is working
|
||||
//! against an isolated process you control, instead of injecting into
|
||||
//! arbitrary running programs.
|
||||
//!
|
||||
//! Usage:
|
||||
//! 1. Build the workspace: `cargo build --workspace`
|
||||
//! 2. Run this binary in one terminal — it prints its PID on line 1.
|
||||
//! 3. In the TimeMocker UI, type that PID into Inject by PID and set a fake time.
|
||||
//! 4. Watch the times printed by this binary shift by the delta you set.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
use windows_sys::Win32::System::SystemInformation::{GetLocalTime, GetSystemTime};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||
|
||||
type FnGetSystemTimeAsFileTime = unsafe extern "system" fn(*mut FILETIME);
|
||||
type FnNtQuerySystemTime = unsafe extern "system" fn(*mut i64) -> i32;
|
||||
|
||||
fn main() {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
|
||||
println!("================================================================");
|
||||
println!(" time-mocker test target");
|
||||
println!(" PID = {pid}");
|
||||
println!(" copy this PID into the TimeMocker UI to inject the hook here.");
|
||||
println!(" press Ctrl+C to stop.");
|
||||
println!("================================================================");
|
||||
println!();
|
||||
|
||||
// Resolve the two APIs that windows-sys' default features don't always
|
||||
// surface (GetSystemTimeAsFileTime + NtQuerySystemTime). Matching the
|
||||
// hook DLL's resolution strategy (`hooks.rs::resolve`) keeps the call
|
||||
// sites symmetric — what the hook hooks, this binary calls.
|
||||
let get_system_time_as_file_time =
|
||||
unsafe { resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimeAsFileTime") };
|
||||
let get_system_time_precise_as_file_time = unsafe {
|
||||
resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
|
||||
};
|
||||
let nt_query_system_time =
|
||||
unsafe { resolve::<FnNtQuerySystemTime>("ntdll.dll", "NtQuerySystemTime") };
|
||||
|
||||
if get_system_time_as_file_time.is_none() {
|
||||
eprintln!("warn: GetSystemTimeAsFileTime not found");
|
||||
}
|
||||
if get_system_time_precise_as_file_time.is_none() {
|
||||
eprintln!("warn: GetSystemTimePreciseAsFileTime not found (pre-Win8?)");
|
||||
}
|
||||
if nt_query_system_time.is_none() {
|
||||
eprintln!("warn: NtQuerySystemTime not found");
|
||||
}
|
||||
|
||||
let mut tick: u64 = 0;
|
||||
loop {
|
||||
tick += 1;
|
||||
println!("--- sample #{tick} ---");
|
||||
|
||||
// GetSystemTime — UTC SYSTEMTIME (kernel32).
|
||||
let mut st_utc: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { GetSystemTime(&mut st_utc) };
|
||||
println!(
|
||||
" GetSystemTime UTC {}",
|
||||
fmt_systemtime(&st_utc)
|
||||
);
|
||||
|
||||
// GetLocalTime — local SYSTEMTIME (kernel32).
|
||||
let mut st_local: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { GetLocalTime(&mut st_local) };
|
||||
println!(
|
||||
" GetLocalTime local {}",
|
||||
fmt_systemtime(&st_local)
|
||||
);
|
||||
|
||||
// GetSystemTimeAsFileTime — FILETIME 100-ns ticks since 1601-01-01 UTC.
|
||||
if let Some(f) = get_system_time_as_file_time {
|
||||
let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { f(&mut ft) };
|
||||
println!(
|
||||
" GetSystemTimeAsFileTime {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
}
|
||||
|
||||
// GetSystemTimePreciseAsFileTime — same units, sub-µs precision.
|
||||
if let Some(f) = get_system_time_precise_as_file_time {
|
||||
let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { f(&mut ft) };
|
||||
println!(
|
||||
" GetSystemTimePreciseAsFT {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
}
|
||||
|
||||
// NtQuerySystemTime — raw i64 (also 100-ns ticks since 1601-01-01 UTC).
|
||||
if let Some(f) = nt_query_system_time {
|
||||
let mut ticks: i64 = 0;
|
||||
let status = unsafe { f(&mut ticks) };
|
||||
if status == 0 {
|
||||
let ft = FILETIME {
|
||||
dwLowDateTime: (ticks as u64 & 0xFFFF_FFFF) as u32,
|
||||
dwHighDateTime: ((ticks as u64) >> 32) as u32,
|
||||
};
|
||||
println!(
|
||||
" NtQuerySystemTime {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
} else {
|
||||
println!(" NtQuerySystemTime NTSTATUS={status:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn resolve<F: Copy>(module: &str, proc_name: &str) -> Option<F> {
|
||||
let module_c = CString::new(module).ok()?;
|
||||
let proc_c = CString::new(proc_name).ok()?;
|
||||
let h = GetModuleHandleA(module_c.as_ptr() as *const u8);
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
let addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?;
|
||||
Some(std::mem::transmute_copy::<_, F>(&addr))
|
||||
}
|
||||
|
||||
fn fmt_systemtime(st: &SYSTEMTIME) -> String {
|
||||
format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
fn fmt_filetime_as_systemtime(ft: &FILETIME) -> String {
|
||||
// Render the FILETIME via FileTimeToSystemTime so the columns line up
|
||||
// with the SYSTEMTIME-returning APIs above.
|
||||
use windows_sys::Win32::System::Time::FileTimeToSystemTime;
|
||||
let mut st: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
let ok = unsafe { FileTimeToSystemTime(ft, &mut st) };
|
||||
if ok == 0 {
|
||||
let raw: u64 = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64;
|
||||
format!("(raw {raw}; FileTimeToSystemTime failed)")
|
||||
} else {
|
||||
format!("UTC {}", fmt_systemtime(&st))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
[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"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
anyhow = "1.0"
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-manifest = "1.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
# Test-only: exercises `#[serde(default)]` on `Persistent`/`Rule` against the
|
||||
# exact format `eframe`'s persistence feature actually uses (RON), rather
|
||||
# than a stand-in format that wouldn't catch a real deserialization gap.
|
||||
ron = "0.8"
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 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).
|
||||
|
||||
use embed_manifest::manifest::ExecutionLevel;
|
||||
use embed_manifest::{embed_manifest, new_manifest};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Only embed the UAC manifest in release builds — otherwise `cargo test`
|
||||
// and other dev workflows would fail with ERROR_ELEVATION_REQUIRED (740).
|
||||
let is_release = std::env::var("PROFILE").is_ok_and(|p| p == "release");
|
||||
// `CARGO_CFG_WINDOWS` describes the *target*, unlike `#[cfg(windows)]`
|
||||
// which in a build script describes the host and breaks cross-compiles.
|
||||
let targets_windows = std::env::var_os("CARGO_CFG_WINDOWS").is_some();
|
||||
|
||||
if is_release && targets_windows {
|
||||
let manifest = new_manifest("TimeMocker.UI")
|
||||
.requested_execution_level(ExecutionLevel::RequireAdministrator);
|
||||
embed_manifest(manifest).expect("failed to embed UAC manifest");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,575 @@
|
||||
//! Per-process injection state.
|
||||
//!
|
||||
//! For each injected PID we keep:
|
||||
//! - the `dll-syringe` process handle (so the DLL stays loaded)
|
||||
//! - 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, VecDeque};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use dll_syringe::process::OwnedProcess;
|
||||
use dll_syringe::Syringe;
|
||||
use time_mocker_core::{
|
||||
local_mmf_name_for_pid, mmf_name_for_pid, CreateOutcome, SharedDeltaWriter,
|
||||
};
|
||||
|
||||
use crate::win32_process_info::{is_native_x64, query_full_image_name, IMAGE_FILE_MACHINE_AMD64};
|
||||
|
||||
/// Windows `ERROR_ACCESS_DENIED`. `CreateFileMappingW` on a `Global\` name returns
|
||||
/// this when the caller's token lacks `SeCreateGlobalPrivilege` (i.e. the UI is
|
||||
/// not running elevated). We use it as the trigger to fall back to `Local\`.
|
||||
const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
|
||||
/// 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,
|
||||
/// Process start time (seconds since Unix epoch, per `sysinfo`) captured
|
||||
/// at inject time. Compared against the watcher's latest snapshot on
|
||||
/// every scan to detect PID reuse: if a different process now owns this
|
||||
/// PID, its start time will not match.
|
||||
start_time: u64,
|
||||
delta: SharedDeltaWriter,
|
||||
_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: VecDeque<String>,
|
||||
/// One-shot guard so the "running unelevated → Local\ fallback" warning is
|
||||
/// logged once per session, not on every auto-inject scan tick.
|
||||
local_fallback_warned: bool,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// 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: VecDeque::new(),
|
||||
local_fallback_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hook_dll_path(&self) -> &Path {
|
||||
&self.hook_dll_path
|
||||
}
|
||||
|
||||
pub fn is_injected(&self, pid: u32) -> bool {
|
||||
self.injected.contains_key(&pid)
|
||||
}
|
||||
|
||||
/// Try `Global\TimeMocker_<pid>` first, then fall back to
|
||||
/// `Local\TimeMocker_<pid>` on ERROR_ACCESS_DENIED (the controller is not
|
||||
/// elevated). Cross-session reach is lost in the fallback path, but the
|
||||
/// hook DLL probes both namespaces so same-session targets still work.
|
||||
fn create_mmf_with_fallback(
|
||||
&mut self,
|
||||
pid: u32,
|
||||
) -> Result<(String, SharedDeltaWriter, CreateOutcome)> {
|
||||
let global = mmf_name_for_pid(pid);
|
||||
match SharedDeltaWriter::create(&global) {
|
||||
Ok((delta, outcome)) => Ok((global, delta, outcome)),
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
if !self.local_fallback_warned {
|
||||
self.local_fallback_warned = true;
|
||||
self.log_push(
|
||||
"warn: controller not elevated — falling back to Local\\ namespace; \
|
||||
cross-session targets will not be reachable. Run as Administrator \
|
||||
(release build) for full reach."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let local = local_mmf_name_for_pid(pid);
|
||||
let (delta, outcome) = SharedDeltaWriter::create(&local).with_context(|| {
|
||||
format!("create MMF {local} (after {global} returned access denied)")
|
||||
})?;
|
||||
// Per-pid note in addition to the once-per-session warning
|
||||
// above: a Local\ target in a different session (session 0
|
||||
// services, another logged-in user, an elevated target) will
|
||||
// not actually observe this mock even though inject reports
|
||||
// success, since the hook resolves the same name to a
|
||||
// different kernel object there.
|
||||
self.log_push(format!(
|
||||
"note: pid={pid} uses Local\\ namespace (unelevated controller) — \
|
||||
confirm it is in this session, or mocking will be a silent no-op"
|
||||
));
|
||||
Ok((local, delta, outcome))
|
||||
}
|
||||
Err(e) => Err(anyhow::Error::from(e).context(format!("create MMF {global}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
start_time: u64,
|
||||
initial_delta: i64,
|
||||
) -> Result<()> {
|
||||
match self.inject_inner(pid, name, path, start_time, 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,
|
||||
start_time: u64,
|
||||
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()
|
||||
));
|
||||
}
|
||||
|
||||
// Open the process handle FIRST and use it for every identity check
|
||||
// below. Holding an open handle pins the PID — the kernel cannot
|
||||
// recycle it for a different process — so unlike re-opening by PID
|
||||
// at each step, there is no window between "verified" and "used"
|
||||
// for the PID to have been reassigned.
|
||||
let process =
|
||||
OwnedProcess::from_pid(pid).with_context(|| format!("open process pid={pid}"))?;
|
||||
let handle = process.as_raw_handle();
|
||||
|
||||
// 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(handle)
|
||||
.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(handle) {
|
||||
return Err(anyhow!(
|
||||
"pid={pid} is not a native x64 process; the AMD64 hook DLL cannot be injected"
|
||||
));
|
||||
}
|
||||
|
||||
let (mmf_name, delta, outcome) = self.create_mmf_with_fallback(pid)?;
|
||||
if outcome == CreateOutcome::Existed {
|
||||
self.log_push(format!(
|
||||
"warn: MMF {mmf_name} pre-existed (stale prior session?)"
|
||||
));
|
||||
}
|
||||
delta.write_delta(initial_delta);
|
||||
|
||||
// Verify the channel actually carries the write before committing to
|
||||
// the injection. Without this, a non-functional mapping (e.g. a
|
||||
// handle to a stale object a crashed prior session left behind)
|
||||
// would still report "Injected" while the target never observes any
|
||||
// delta — a silent no-op mock.
|
||||
let readback = time_mocker_core::SharedDeltaReader::open(&mmf_name)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| {
|
||||
let seen = r.read_delta();
|
||||
if seen == initial_delta {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("wrote delta {initial_delta} but read back {seen}"))
|
||||
}
|
||||
});
|
||||
if let Err(e) = readback {
|
||||
return Err(e.context(format!("verify delta channel {mmf_name} is writable")));
|
||||
}
|
||||
|
||||
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(),
|
||||
start_time,
|
||||
delta,
|
||||
_syringe: syringe,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_delta_all(&self, ticks: i64) {
|
||||
for proc in self.injected.values() {
|
||||
proc.write_delta(ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
p.write_delta(0);
|
||||
self.log_push(format!("Disabled [{pid}] {}", p.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop entries for processes that have exited, and separately for PIDs
|
||||
/// that are still alive but now belong to a *different* process — the
|
||||
/// original one exited and Windows recycled its PID within a scan
|
||||
/// window. `alive` maps every currently-alive PID to its process start
|
||||
/// time (from `sysinfo`, refreshed on every scan); a mismatch against
|
||||
/// the start time captured at inject time means the PID was reused.
|
||||
/// Without this, the stale entry would keep reporting `is_injected() ==
|
||||
/// true` and silently write deltas nobody reads.
|
||||
pub fn prune_dead(&mut self, alive: &HashMap<u32, u64>) {
|
||||
let mut exited = Vec::new();
|
||||
let mut recycled = Vec::new();
|
||||
for (pid, proc) in &self.injected {
|
||||
match alive.get(pid) {
|
||||
None => exited.push(*pid),
|
||||
Some(&start_time) if start_time != proc.start_time => recycled.push(*pid),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for pid in exited {
|
||||
self.injected.remove(&pid);
|
||||
}
|
||||
for pid in recycled {
|
||||
if let Some(p) = self.injected.remove(&pid) {
|
||||
self.log_push(format!(
|
||||
"warn: pid={pid} ({}) was recycled by a different process — stopped mocking it",
|
||||
p.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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", ""));
|
||||
}
|
||||
|
||||
fn empty_manager() -> InjectionManager {
|
||||
InjectionManager {
|
||||
injected: HashMap::new(),
|
||||
hook_dll_path: std::path::PathBuf::from("dummy.dll"),
|
||||
log: VecDeque::new(),
|
||||
local_fallback_warned: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a real `InjectedProcess` entry against the *current* test
|
||||
/// process — the only PID a test can legitimately hold a handle to and
|
||||
/// create a working MMF for without spawning a child process.
|
||||
fn self_injected_process(mmf_suffix: &str, start_time: u64) -> InjectedProcess {
|
||||
let pid = std::process::id();
|
||||
let mmf_name = format!("TimeMockerTest_prune_{mmf_suffix}_{pid}");
|
||||
let (delta, _outcome) = SharedDeltaWriter::create(&mmf_name).expect("create test MMF");
|
||||
let process = OwnedProcess::from_pid(pid).expect("open self process");
|
||||
let syringe = Syringe::for_process(process);
|
||||
InjectedProcess {
|
||||
pid,
|
||||
name: "self".into(),
|
||||
path: String::new(),
|
||||
start_time,
|
||||
delta,
|
||||
_syringe: syringe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_dead_evicts_exited_pid() {
|
||||
let mut manager = empty_manager();
|
||||
let entry = self_injected_process("exit", 1000);
|
||||
let pid = entry.pid;
|
||||
manager.injected.insert(pid, entry);
|
||||
|
||||
manager.prune_dead(&HashMap::new());
|
||||
|
||||
assert!(
|
||||
!manager.is_injected(pid),
|
||||
"pid absent from alive set should be evicted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_dead_evicts_recycled_pid() {
|
||||
let mut manager = empty_manager();
|
||||
let entry = self_injected_process("recycle", 1000);
|
||||
let pid = entry.pid;
|
||||
manager.injected.insert(pid, entry);
|
||||
|
||||
// Same pid alive, but with a different start time — simulates the
|
||||
// original process exiting and the kernel handing the pid to a new,
|
||||
// unrelated process before the next scan.
|
||||
let mut alive = HashMap::new();
|
||||
alive.insert(pid, 2000);
|
||||
manager.prune_dead(&alive);
|
||||
|
||||
assert!(!manager.is_injected(pid), "recycled pid should be evicted");
|
||||
assert!(
|
||||
manager.log.iter().any(|l| l.contains("recycled")),
|
||||
"recycle eviction should be logged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_dead_keeps_matching_start_time() {
|
||||
let mut manager = empty_manager();
|
||||
let entry = self_injected_process("keep", 1000);
|
||||
let pid = entry.pid;
|
||||
manager.injected.insert(pid, entry);
|
||||
|
||||
let mut alive = HashMap::new();
|
||||
alive.insert(pid, 1000);
|
||||
manager.prune_dead(&alive);
|
||||
|
||||
assert!(
|
||||
manager.is_injected(pid),
|
||||
"matching start time should be kept"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injection_manager_log_bounded() {
|
||||
let mut manager = empty_manager();
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 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;
|
||||
mod win32_process_info;
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
let result = eframe::run_native(
|
||||
"TimeMocker",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(app::TimeMockerApp::new(cc)))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("eframe error: {e}"));
|
||||
|
||||
// `windows_subsystem = "windows"` (release builds) detaches stdio, so a
|
||||
// `Result::Err` returned from `main` — the only signal a release launch
|
||||
// failure otherwise produces — has nowhere visible to go: the process
|
||||
// just exits with no window and no message. Surface it with a message
|
||||
// box so a failed launch isn't silent.
|
||||
if let Err(e) = &result {
|
||||
report_startup_failure(&format!("{e:#}"));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Show a blocking message box with the startup error. Best-effort: if even
|
||||
/// this fails to display (e.g. no desktop session), there's nothing further
|
||||
/// we can do — `main`'s `Result::Err` still sets a non-zero exit code.
|
||||
fn report_startup_failure(message: &str) {
|
||||
use std::iter::once;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
|
||||
let wide = |s: &str| -> Vec<u16> { s.encode_utf16().chain(once(0)).collect() };
|
||||
let text = wide(message);
|
||||
let caption = wide("TimeMocker failed to start");
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
std::ptr::null_mut(),
|
||||
text.as_ptr(),
|
||||
caption.as_ptr(),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 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::HashMap;
|
||||
|
||||
use sysinfo::System;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
/// Process start time in seconds since the Unix epoch. Used as a cheap
|
||||
/// identity token: a PID whose start time changed between two scans
|
||||
/// belongs to a different (recycled) process, not the one last seen.
|
||||
pub start_time: u64,
|
||||
}
|
||||
|
||||
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,
|
||||
start_time: proc.start_time(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every currently-alive PID mapped to its process start time. Used by
|
||||
/// `InjectionManager::prune_dead` to distinguish a still-running process
|
||||
/// from a different one that was handed the same (recycled) PID.
|
||||
pub fn alive_with_start_times(&self) -> HashMap<u32, u64> {
|
||||
self.sys
|
||||
.processes()
|
||||
.iter()
|
||||
.map(|(pid, proc)| (pid.as_u32(), proc.start_time()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Auto-inject pattern rules — glob or regex matched against process path and name.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use globset::{GlobBuilder, GlobMatcher};
|
||||
use regex::{Regex, RegexBuilder};
|
||||
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, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Rule {
|
||||
pub pattern: String,
|
||||
pub kind: PatternKind,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Rule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pattern: String::new(),
|
||||
kind: PatternKind::Glob,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
// Windows process paths are unreadable (e.g. access-denied targets)
|
||||
// resolve to an empty string in `ProcInfo::path` — matching that
|
||||
// empty string against a broad pattern like `.*` would make every
|
||||
// unresolvable process match every path-based rule.
|
||||
self.matchers
|
||||
.iter()
|
||||
.any(|m| (!path.is_empty() && m.is_match(path)) || m.is_match(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a pattern without keeping the compiled matcher — used by the UI
|
||||
/// to reject bad input at "+ Add Rule" time and to flag already-stored rules
|
||||
/// that fail to compile (e.g. after manual settings-file edits).
|
||||
pub fn validate_pattern(kind: PatternKind, pattern: &str) -> Result<()> {
|
||||
compile_one(&Rule {
|
||||
pattern: pattern.to_owned(),
|
||||
kind,
|
||||
enabled: true,
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn compile_one(rule: &Rule) -> Result<Matcher> {
|
||||
match rule.kind {
|
||||
PatternKind::Glob => {
|
||||
// Windows process/path names are case-insensitive (`Chrome.exe`
|
||||
// == `chrome.exe`); match case-insensitively so rules typed in
|
||||
// any case still hit.
|
||||
let g = GlobBuilder::new(&rule.pattern)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("glob: {e}"))?;
|
||||
Ok(Matcher::Glob(g.compile_matcher()))
|
||||
}
|
||||
PatternKind::Regex => Ok(Matcher::Regex(
|
||||
RegexBuilder::new(&rule.pattern)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matches_case_insensitive_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("", "CHROME.EXE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matches_case_insensitive_path() {
|
||||
let rules = vec![Rule {
|
||||
pattern: r"C:\Program Files\**".into(),
|
||||
kind: PatternKind::Glob,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(c.matches(r"c:\program files\app\app.exe", "app.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matches_case_insensitive() {
|
||||
let rules = vec![Rule {
|
||||
pattern: r"^myapp\.exe$".into(),
|
||||
kind: PatternKind::Regex,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(c.matches("", "MyApp.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_pattern_accepts_valid_glob() {
|
||||
assert!(validate_pattern(PatternKind::Glob, "*.exe").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_pattern_rejects_invalid_glob() {
|
||||
assert!(validate_pattern(PatternKind::Glob, "[").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_pattern_rejects_invalid_regex() {
|
||||
assert!(validate_pattern(PatternKind::Regex, "(unclosed").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_pattern_accepts_valid_regex() {
|
||||
assert!(validate_pattern(PatternKind::Regex, r"^app\.exe$").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pattern_is_dropped_from_compiled_rules() {
|
||||
let rules = vec![Rule {
|
||||
pattern: "(unclosed".into(),
|
||||
kind: PatternKind::Regex,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(!c.matches("anything", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_path_does_not_match_broad_pattern() {
|
||||
// A pattern that matches only the empty string would match every
|
||||
// unresolvable process's path (`ProcInfo::path` defaults to "" when
|
||||
// the exe path can't be read) unless the empty-path arm is skipped.
|
||||
// Use a process name that never matches so only the path arm could
|
||||
// produce a false positive.
|
||||
let rules = vec![Rule {
|
||||
pattern: "^$".into(),
|
||||
kind: PatternKind::Regex,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(!c.matches("", "notepad.exe"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 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::HANDLE;
|
||||
use windows_sys::Win32::System::Threading::{IsWow64Process2, QueryFullProcessImageNameW};
|
||||
|
||||
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 the process referenced by `handle`, or
|
||||
/// `Err` if the query fails.
|
||||
///
|
||||
/// Takes an already-open handle rather than a PID: opening the handle is the
|
||||
/// caller's responsibility so it can hold that handle across every
|
||||
/// identity-sensitive step (query image name, check bitness, inject).
|
||||
/// Holding the handle pins the PID — Windows will not recycle a PID that
|
||||
/// still has an open handle referencing it — closing the TOCTOU window that
|
||||
/// exists when each step re-opens the process by PID.
|
||||
pub fn query_full_image_name(handle: HANDLE) -> io::Result<String> {
|
||||
unsafe {
|
||||
let mut buf = [0u16; 1024];
|
||||
let mut size = buf.len() as u32;
|
||||
let ok = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
|
||||
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 process referenced by `handle` 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(handle: HANDLE) -> bool {
|
||||
unsafe {
|
||||
let mut process_machine: u16 = 0;
|
||||
let mut native_machine: u16 = 0;
|
||||
let ok = IsWow64Process2(handle, &mut process_machine, &mut native_machine);
|
||||
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;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
|
||||
#[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");
|
||||
|
||||
// `cargo test --workspace` (release CI job) runs before the release
|
||||
// build produces the hook DLL, so this test can't require the DLL
|
||||
// unconditionally without breaking that job. Skip quietly by
|
||||
// default; set TIME_MOCKER_REQUIRE_HOOK_DLL=1 (after a release
|
||||
// build) to make a missing DLL a hard failure instead of a silent
|
||||
// no-op — e.g. in a local verification pass or a dedicated CI step
|
||||
// that runs after `cargo build --release`.
|
||||
if !dll_path.exists() {
|
||||
if std::env::var_os("TIME_MOCKER_REQUIRE_HOOK_DLL").is_some() {
|
||||
panic!(
|
||||
"hook DLL not found at {} and TIME_MOCKER_REQUIRE_HOOK_DLL is set",
|
||||
dll_path.display()
|
||||
);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
fn open_self_handle() -> HANDLE {
|
||||
let self_pid = std::process::id();
|
||||
unsafe {
|
||||
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, self_pid);
|
||||
assert!(!h.is_null(), "OpenProcess on self should succeed");
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_native_x64_self() {
|
||||
// Test on the current process (which must be native x64 if tests run)
|
||||
let h = open_self_handle();
|
||||
let result = is_native_x64(h);
|
||||
unsafe { CloseHandle(h) };
|
||||
// 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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_full_image_name_self() {
|
||||
let h = open_self_handle();
|
||||
let result = query_full_image_name(h);
|
||||
unsafe { CloseHandle(h) };
|
||||
let path = result.expect("query image name for self should succeed");
|
||||
assert!(
|
||||
!path.is_empty(),
|
||||
"self process image path should not be empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
-42
@@ -1,42 +0,0 @@
|
||||
name: Create Draft Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
|
||||
- name: Build Solution
|
||||
run: dotnet build TimeMocker.sln -c Release -p:Platform=x64
|
||||
|
||||
- name: Prepare Release Artifacts
|
||||
run: |
|
||||
mkdir release
|
||||
copy TimeMocker.UI\bin\x64\Release\net48\* release\
|
||||
|
||||
- name: Zip Release
|
||||
run: Compress-Archive -Path release\* -DestinationPath TimeMocker-x64.zip
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: TimeMocker-x64.zip
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,59 +0,0 @@
|
||||
.idea
|
||||
*.DotSettings.user
|
||||
|
||||
|
||||
|
||||
## A streamlined .gitignore for modern .NET projects
|
||||
## including temporary files, build results, and
|
||||
## files generated by popular .NET tools. If you are
|
||||
## developing with Visual Studio, the VS .gitignore
|
||||
## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
## has more thorough IDE-specific entries.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
|
||||
# Others
|
||||
~$*
|
||||
*~
|
||||
CodeCoverage/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
@@ -1,141 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
TimeMocker is a Windows application that injects fake time into running processes by hooking Win32 time APIs. It consists of a WinForms UI controller and an EasyHook-based DLL that gets injected into target processes.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Build the solution (x64 only)
|
||||
dotnet build TimeMocker.sln -c Release -p:Platform=x64
|
||||
|
||||
# Build Debug configuration
|
||||
dotnet build TimeMocker.sln -c Debug -p:Platform=x64
|
||||
|
||||
# Clean and rebuild
|
||||
dotnet clean TimeMocker.sln && dotnet build TimeMocker.sln -c Release -p:Platform=x64
|
||||
```
|
||||
|
||||
**Important**: This is an x64-only project. The solution only has x64 platform configurations. Building for x86 or AnyCPU will fail.
|
||||
|
||||
**Output locations**:
|
||||
- UI exe: `TimeMocker.UI/bin/x64/Release/net48/TimeMocker.exe`
|
||||
- Hook DLL: `TimeMocker.UI/bin/x64/Release/net48/TimeMocker.Hook.dll`
|
||||
|
||||
The hook DLL must be next to the UI exe (automatically copied via ProjectReference).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TimeMocker.sln
|
||||
├── TimeMocker.UI/ — WinForms controller (requires Admin elevation)
|
||||
│ ├── Forms/MainForm.cs — Main UI with process selection, time picker, pattern manager
|
||||
│ ├── Core/InjectionManager.cs — EasyHook-based injector, manages injected processes
|
||||
│ ├── Core/ProcessWatcher.cs — Background scanner for auto-inject rules (poll-based)
|
||||
│ └── Core/SharedMemoryManager.cs — Named MMF for IPC with hook DLL
|
||||
│
|
||||
└── TimeMocker.Hook/ — DLL injected into target processes
|
||||
└── InjectionEntryPoint.cs — Hooks 5 Win32 time APIs via EasyHook
|
||||
```
|
||||
|
||||
### IPC Design
|
||||
|
||||
The fake time is stored in a **named Memory-Mapped File** (one per injected process):
|
||||
|
||||
- Name format: `TimeMocker_<PID>`
|
||||
- Size: 12 bytes (`Marshal.SizeOf<MockTimeInfo>()`)
|
||||
- Layout:
|
||||
- `[0..7]` FakeUtcTicks (Int64 — DateTime.Ticks)
|
||||
- `[8..11]` Enabled (Int32 — 0=passthrough, 1=mock)
|
||||
|
||||
The hook reads this on every time API call (~50 ns read, no syscall). The UI writes via `SharedMemoryManager.Write()`.
|
||||
|
||||
### Hooked APIs
|
||||
|
||||
| API | DLL |
|
||||
|-----|-----|
|
||||
| `GetSystemTime` | kernel32 |
|
||||
| `GetLocalTime` | kernel32 |
|
||||
| `GetSystemTimeAsFileTime` | kernel32 |
|
||||
| `GetSystemTimePreciseAsFileTime` | kernel32 |
|
||||
| `NtQuerySystemTime` | ntdll |
|
||||
|
||||
All hooked functions share the same `GetFakeUtc()` logic which reads from shared memory and either returns the fake time or real `DateTime.UtcNow` depending on the Enabled flag.
|
||||
|
||||
### Auto-Inject Pattern Matching
|
||||
|
||||
The `ProcessWatcher` supports both glob and regex patterns for matching process paths or names:
|
||||
|
||||
- Glob: `C:\Games\MyGame\*` or `*chrome*` (converted to regex internally)
|
||||
- Regex: `^.*\\MyApp\.exe$`
|
||||
|
||||
Patterns are matched against both the full process path (`MainModule.FileName`) and process name (`ProcessName`).
|
||||
|
||||
## Key Components
|
||||
|
||||
### InjectionManager
|
||||
|
||||
- Manages the dictionary of injected processes (`Dictionary<int, InjectedProcess>`)
|
||||
- Uses `RemoteHooking.Inject()` to inject `TimeMocker.Hook.dll` into target processes
|
||||
- Creates a `SharedMemoryManager` instance per process for IPC
|
||||
- Handles eject by disabling the mock (setting Enabled=0) before disposing shared memory
|
||||
|
||||
### SharedMemoryManager
|
||||
|
||||
- Creates a named memory-mapped file using `MemoryMappedFile.CreateOrOpen()`
|
||||
- `MockTimeInfo` struct is blittable (sequential layout) for direct memory write
|
||||
- Must be disposed when process is ejected
|
||||
|
||||
### ProcessWatcher
|
||||
|
||||
- Polls running processes every ~1.5 seconds (configurable)
|
||||
- Tracks seen process IDs to avoid duplicate injections
|
||||
- Auto-injects when a process matches any enabled rule
|
||||
- Uses current `FakeUtc` and `MockEnabled` settings at injection time
|
||||
|
||||
### InjectionEntryPoint (Hook DLL)
|
||||
|
||||
- Implements `IEntryPoint` from EasyHook
|
||||
- `Run()` method opens the named MMF, installs hooks, and keeps alive with a sleep loop
|
||||
- Uses `LocalHook.Create()` with thread ACL set to exclude thread 0 (all threads)
|
||||
- Hook delegates read the shared memory on each call (hot path optimized)
|
||||
- Logs errors to `%TEMP%\TimeMocker.Hook.log`
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Target Framework**: .NET Framework 4.8 (pre-installed on Windows 10+)
|
||||
- **Unsafe Code**: Both projects use `AllowUnsafeBlocks=true` for memory-mapped file operations
|
||||
- **Dependencies**: EasyHook 2.7.7030.0 (available via NuGet)
|
||||
- **Language Version**: C# 8
|
||||
|
||||
### Limitations
|
||||
|
||||
- **64-bit only**: 32-bit processes require a separate 32-bit hook DLL build
|
||||
- **No hot-unload support**: EasyHook doesn't fully support DLL ejection; disable mock instead
|
||||
- **Protected processes**: Anti-cheat services (Epic, BattlEye, etc.) will block injection
|
||||
- **QueryPerformanceCounter**: Not affected (hardware register, cannot be hooked via EasyHook)
|
||||
|
||||
### Time Epoch
|
||||
|
||||
FILETIME epoch is January 1, 1601 (UTC). Hooked functions returning FILETIME convert from DateTime.Ticks using:
|
||||
```csharp
|
||||
var epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
long fileTimeTicks = (fakeUtc - epoch).Ticks;
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a new time API hook
|
||||
1. Add the hook delegate in `InjectionEntryPoint.cs` with `[UnmanagedFunctionPointer(CallingConvention.StdCall)]`
|
||||
2. Add a `LocalHook` field for the hook handle
|
||||
3. Create the hook in `InstallHooks()` using `LocalHook.GetProcAddress(dllName, functionName)`
|
||||
4. Implement the hook function that calls `GetFakeUtc()` and returns the appropriate format
|
||||
|
||||
### Modify auto-inject polling interval
|
||||
Pass a different interval to `ProcessWatcher.Start(int pollIntervalMs)` when enabling the watcher in `MainForm.cs`.
|
||||
|
||||
### Debug hook DLL
|
||||
The hook writes errors to `%TEMP%\TimeMocker.Hook.log`. For more detailed debugging, you may need to attach a debugger to the target process after injection.
|
||||
@@ -1,115 +0,0 @@
|
||||
# TimeMocker
|
||||
|
||||
A Windows application that injects fake time into running processes by hooking Win32 time APIs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TimeMocker.sln
|
||||
├── TimeMocker.UI — WinForms controller app (run as Admin)
|
||||
│ ├── Forms/MainForm — UI: process list with inject toggle, time picker, pattern manager
|
||||
│ ├── Core/InjectionManager — EasyHook-based injector per process
|
||||
│ ├── Core/ProcessWatcher — background scanner for auto-inject patterns
|
||||
│ └── Core/SharedMemoryManager — named MMF shared with the hook DLL
|
||||
│
|
||||
└── TimeMocker.Hook — DLL injected into target processes
|
||||
└── InjectionEntryPoint — hooks 5 Win32 time functions via EasyHook
|
||||
```
|
||||
|
||||
## Hooked APIs
|
||||
|
||||
| API | DLL |
|
||||
|-----|-----|
|
||||
| `GetSystemTime` | kernel32 |
|
||||
| `GetLocalTime` | kernel32 |
|
||||
| `GetSystemTimeAsFileTime` | kernel32 |
|
||||
| `GetSystemTimePreciseAsFileTime` | kernel32 |
|
||||
| `NtQuerySystemTime` | ntdll |
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Windows 10/11 x64**
|
||||
- **.NET Framework 4.8** (pre-installed on Win10+)
|
||||
- **Visual Studio 2022** or `dotnet build`
|
||||
- Must run as **Administrator** (UAC prompt shown automatically)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Clone / extract the solution
|
||||
cd TimeMocker
|
||||
dotnet restore
|
||||
dotnet build -c Release -p:Platform=x64
|
||||
|
||||
# Outputs go to:
|
||||
# TimeMocker.UI/bin/x64/Release/net48/TimeMocker.exe
|
||||
# TimeMocker.UI/bin/x64/Release/net48/TimeMocker.Hook.dll ← must be next to .exe
|
||||
```
|
||||
|
||||
> In Visual Studio: open `TimeMocker.sln`, set platform to **x64**, build solution.
|
||||
|
||||
## Usage
|
||||
|
||||
### Manual Injection
|
||||
|
||||
1. Launch `TimeMocker.exe` (UAC will prompt for elevation)
|
||||
2. Set the desired date/time in the **Mock Time Settings** bar at the top
|
||||
3. Click **Set** to apply the time to all injected processes
|
||||
4. Go to the **Processes** tab
|
||||
5. Find your target process in the list (use Search if needed)
|
||||
6. Check the **Injected** checkbox next to the process
|
||||
7. The target process now sees your fake time immediately
|
||||
8. To stop mocking for a process, simply uncheck the **Injected** checkbox
|
||||
|
||||
### Auto-Inject Rules
|
||||
|
||||
The auto-inject watcher starts automatically when TimeMocker launches. Any process matching a rule will be injected automatically.
|
||||
|
||||
1. Go to the **Auto-Inject Rules** tab
|
||||
2. Enter a pattern matching the process path or name, e.g.:
|
||||
- Glob: `C:\Games\MyGame\*`
|
||||
- Glob by name: `*chrome*`
|
||||
- Regex: `^.*\\MyApp\.exe$`
|
||||
3. Select **Glob** or **Regex** pattern type
|
||||
4. Click **+ Add Rule**
|
||||
5. Any process that starts (or is already running) matching the rule gets injected automatically with the current mock time
|
||||
|
||||
### Time Flow
|
||||
|
||||
TimeMocker uses a **delta-based** approach. When you set a fake time, it calculates the offset between your desired time and the current real time. This offset is stored and applied continuously, so the fake time flows forward naturally at the same rate as real time.
|
||||
|
||||
- Click **Now** to reset the date/time pickers to current time
|
||||
- Click **Set** to apply the selected time to all injected processes
|
||||
- The offset is recalculated each time you click **Set**
|
||||
|
||||
### Process List
|
||||
|
||||
- Only user processes are shown (system processes are filtered out)
|
||||
- Click **⟳ Refresh** to reload the process list
|
||||
- Dead processes are automatically removed from the injected processes list
|
||||
|
||||
## IPC Design
|
||||
|
||||
The time offset is stored in a **named Memory-Mapped File** (one per injected process):
|
||||
|
||||
```
|
||||
Name: TimeMocker_<PID>
|
||||
Size: 8 bytes
|
||||
[0..7] DeltaTicks (Int64 — offset from DateTime.UtcNow.Ticks)
|
||||
```
|
||||
|
||||
The hook reads this on every time API call and returns `DateTime.UtcNow + DeltaTicks`. This design allows the fake time to flow naturally without requiring timer-based updates from the UI.
|
||||
|
||||
## Notes & Limitations
|
||||
|
||||
- **64-bit only** — 32-bit processes require a separate 32-bit hook DLL build
|
||||
- Processes using `QueryPerformanceCounter` for *monotonic* timing are not affected
|
||||
(QPC is a hardware register; patching it is unsupported by EasyHook)
|
||||
- Anti-cheat or heavily protected processes (Epic, BattlEye, etc.) will block injection
|
||||
- Some .NET apps read time through the CLR, which internally calls the hooked APIs — these *will* be affected
|
||||
- EasyHook does not fully support hot-eject; to restore real time, uncheck the **Injected** checkbox
|
||||
- Mocked time is always enabled — there is no passthrough mode
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,188 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using EasyHook;
|
||||
|
||||
namespace TimeMocker.Hook
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared memory layout written by the UI and read by the hook.
|
||||
/// Stored in a named memory-mapped file so no pipe latency on hot path.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MockTimeInfo
|
||||
{
|
||||
public long DeltaTicks; // Offset to add to DateTime.UtcNow.Ticks (can be negative)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Win32 structs
|
||||
// -------------------------------------------------------------------------
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct SYSTEMTIME
|
||||
{
|
||||
public ushort wYear, wMonth, wDayOfWeek, wDay;
|
||||
public ushort wHour, wMinute, wSecond, wMilliseconds;
|
||||
|
||||
public static SYSTEMTIME FromDateTime(DateTime dt)
|
||||
{
|
||||
return new SYSTEMTIME
|
||||
{
|
||||
wYear = (ushort)dt.Year,
|
||||
wMonth = (ushort)dt.Month,
|
||||
wDayOfWeek = (ushort)dt.DayOfWeek,
|
||||
wDay = (ushort)dt.Day,
|
||||
wHour = (ushort)dt.Hour,
|
||||
wMinute = (ushort)dt.Minute,
|
||||
wSecond = (ushort)dt.Second,
|
||||
wMilliseconds = (ushort)dt.Millisecond
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EasyHook entry point – called after DLL is injected
|
||||
// -------------------------------------------------------------------------
|
||||
public class InjectionEntryPoint : IEntryPoint
|
||||
{
|
||||
private readonly string _mmfName;
|
||||
private System.IO.MemoryMappedFiles.MemoryMappedFile _mmf;
|
||||
private System.IO.MemoryMappedFiles.MemoryMappedViewAccessor _view;
|
||||
|
||||
// Hook handles
|
||||
private LocalHook _getSystemTimeHook;
|
||||
private LocalHook _getLocalTimeHook;
|
||||
private LocalHook _ntQuerySystemTimeHook;
|
||||
private LocalHook _getSystemTimeAsFileTimeHook;
|
||||
private LocalHook _getSystemTimePreciseAsFileTimeHook;
|
||||
|
||||
public InjectionEntryPoint(RemoteHooking.IContext context, string mmfName)
|
||||
{
|
||||
_mmfName = mmfName;
|
||||
}
|
||||
|
||||
public void Run(RemoteHooking.IContext context, string mmfName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Open the shared memory created by the UI process
|
||||
_mmf = System.IO.MemoryMappedFiles.MemoryMappedFile.OpenExisting(mmfName);
|
||||
_view = _mmf.CreateViewAccessor(0, Marshal.SizeOf<MockTimeInfo>());
|
||||
|
||||
InstallHooks();
|
||||
RemoteHooking.WakeUpProcess();
|
||||
|
||||
// Keep alive until process exits
|
||||
while (true) Thread.Sleep(500);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.AppendAllText(
|
||||
Path.Combine(Path.GetTempPath(), "TimeMocker.Hook.log"),
|
||||
$"[{DateTime.Now}] ERROR: {ex}\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_getSystemTimeHook?.Dispose();
|
||||
_getLocalTimeHook?.Dispose();
|
||||
_ntQuerySystemTimeHook?.Dispose();
|
||||
_getSystemTimeAsFileTimeHook?.Dispose();
|
||||
_getSystemTimePreciseAsFileTimeHook?.Dispose();
|
||||
_view?.Dispose();
|
||||
_mmf?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private MockTimeInfo ReadMockInfo()
|
||||
{
|
||||
_view.Read(0, out MockTimeInfo info);
|
||||
return info;
|
||||
}
|
||||
|
||||
private DateTime GetFakeUtc()
|
||||
{
|
||||
var info = ReadMockInfo();
|
||||
// Return real UTC time plus the stored delta offset
|
||||
return new DateTime(DateTime.UtcNow.Ticks + info.DeltaTicks, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private void InstallHooks()
|
||||
{
|
||||
_getSystemTimeHook = LocalHook.Create(
|
||||
LocalHook.GetProcAddress("kernel32.dll", "GetSystemTime"),
|
||||
new GetSystemTimeDelegate(GetSystemTime_Hook), this);
|
||||
_getSystemTimeHook.ThreadACL.SetExclusiveACL(new[] { 0 });
|
||||
|
||||
_getLocalTimeHook = LocalHook.Create(
|
||||
LocalHook.GetProcAddress("kernel32.dll", "GetLocalTime"),
|
||||
new GetLocalTimeDelegate(GetLocalTime_Hook), this);
|
||||
_getLocalTimeHook.ThreadACL.SetExclusiveACL(new[] { 0 });
|
||||
|
||||
_ntQuerySystemTimeHook = LocalHook.Create(
|
||||
LocalHook.GetProcAddress("ntdll.dll", "NtQuerySystemTime"),
|
||||
new NtQuerySystemTimeDelegate(NtQuerySystemTime_Hook), this);
|
||||
_ntQuerySystemTimeHook.ThreadACL.SetExclusiveACL(new[] { 0 });
|
||||
|
||||
_getSystemTimeAsFileTimeHook = LocalHook.Create(
|
||||
LocalHook.GetProcAddress("kernel32.dll", "GetSystemTimeAsFileTime"),
|
||||
new GetSystemTimeAsFileTimeDelegate(GetSystemTimeAsFileTime_Hook), this);
|
||||
_getSystemTimeAsFileTimeHook.ThreadACL.SetExclusiveACL(new[] { 0 });
|
||||
|
||||
_getSystemTimePreciseAsFileTimeHook = LocalHook.Create(
|
||||
LocalHook.GetProcAddress("kernel32.dll", "GetSystemTimePreciseAsFileTime"),
|
||||
new GetSystemTimeAsFileTimeDelegate(GetSystemTimePreciseAsFileTime_Hook), this);
|
||||
_getSystemTimePreciseAsFileTimeHook.ThreadACL.SetExclusiveACL(new[] { 0 });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hook implementations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate void GetSystemTimeDelegate(out SYSTEMTIME lpSystemTime);
|
||||
|
||||
private void GetSystemTime_Hook(out SYSTEMTIME lpSystemTime)
|
||||
{
|
||||
lpSystemTime = SYSTEMTIME.FromDateTime(GetFakeUtc());
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate void GetLocalTimeDelegate(out SYSTEMTIME lpLocalTime);
|
||||
|
||||
private void GetLocalTime_Hook(out SYSTEMTIME lpLocalTime)
|
||||
{
|
||||
lpLocalTime = SYSTEMTIME.FromDateTime(GetFakeUtc().ToLocalTime());
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int NtQuerySystemTimeDelegate(out long systemTime);
|
||||
|
||||
private int NtQuerySystemTime_Hook(out long systemTime)
|
||||
{
|
||||
// FILETIME epoch: Jan 1, 1601
|
||||
var epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
systemTime = (GetFakeUtc() - epoch).Ticks;
|
||||
return 0; // STATUS_SUCCESS
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate void GetSystemTimeAsFileTimeDelegate(out long lpFileTime);
|
||||
|
||||
private void GetSystemTimeAsFileTime_Hook(out long lpFileTime)
|
||||
{
|
||||
var epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
lpFileTime = (GetFakeUtc() - epoch).Ticks;
|
||||
}
|
||||
|
||||
private void GetSystemTimePreciseAsFileTime_Hook(out long lpFileTime)
|
||||
{
|
||||
var epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
lpFileTime = (GetFakeUtc() - epoch).Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<Platforms>x64;x86</Platforms>
|
||||
<AssemblyName>TimeMocker.Hook</AssemblyName>
|
||||
<RootNamespace>TimeMocker.Hook</RootNamespace>
|
||||
<OutputType>Library</OutputType>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PlatformTarget>$(Platform)</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EasyHook" Version="2.7.7097.0"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,133 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using EasyHook;
|
||||
|
||||
namespace TimeMocker.UI.Core
|
||||
{
|
||||
public class InjectedProcess
|
||||
{
|
||||
public int ProcessId { get; set; }
|
||||
public string ProcessName { get; set; }
|
||||
public string ProcessPath { get; set; }
|
||||
public SharedMemoryManager Shm { get; set; }
|
||||
public bool IsInjected { get; set; }
|
||||
}
|
||||
|
||||
public class InjectionManager : IDisposable
|
||||
{
|
||||
private readonly Dictionary<int, InjectedProcess> _injected
|
||||
= new Dictionary<int, InjectedProcess>();
|
||||
|
||||
private static readonly string HookDllPathX64 =
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TimeMocker.Hook.x64.dll");
|
||||
|
||||
private static readonly string HookDllPathX86 =
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TimeMocker.Hook.x86.dll");
|
||||
|
||||
public event Action<string> LogMessage;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Inject into a specific process
|
||||
// -----------------------------------------------------------------------
|
||||
public InjectedProcess Inject(Process process)
|
||||
{
|
||||
if (_injected.ContainsKey(process.Id))
|
||||
return _injected[process.Id];
|
||||
|
||||
var entry = new InjectedProcess
|
||||
{
|
||||
ProcessId = process.Id,
|
||||
ProcessName = process.ProcessName,
|
||||
ProcessPath = TryGetPath(process),
|
||||
Shm = new SharedMemoryManager(process.Id)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Write initial delta (zero = real time) before hook starts reading
|
||||
entry.Shm.Write(new MockTimeInfo { DeltaTicks = 0 });
|
||||
|
||||
RemoteHooking.Inject(
|
||||
process.Id,
|
||||
InjectionOptions.DoNotRequireStrongName,
|
||||
HookDllPathX86,
|
||||
HookDllPathX64,
|
||||
entry.Shm.MmfName);
|
||||
|
||||
entry.IsInjected = true;
|
||||
_injected[process.Id] = entry;
|
||||
Log($"Injected into [{process.Id}] {process.ProcessName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
entry.Shm.Dispose();
|
||||
Log($"Failed to inject into [{process.Id}] {process.ProcessName}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update fake time for a process
|
||||
// -----------------------------------------------------------------------
|
||||
public void SetFakeTime(int processId, DateTime fakeUtc)
|
||||
{
|
||||
if (!_injected.TryGetValue(processId, out var entry)) return;
|
||||
|
||||
// Calculate delta: (desired fake time) - (current real UTC time)
|
||||
long deltaTicks = fakeUtc.Ticks - DateTime.UtcNow.Ticks;
|
||||
|
||||
entry.Shm.Write(new MockTimeInfo { DeltaTicks = deltaTicks });
|
||||
}
|
||||
|
||||
public void SetFakeTimeAll(DateTime fakeUtc)
|
||||
{
|
||||
foreach (var pid in _injected.Keys)
|
||||
SetFakeTime(pid, fakeUtc);
|
||||
}
|
||||
|
||||
public bool IsInjected(int processId)
|
||||
{
|
||||
return _injected.ContainsKey(processId);
|
||||
}
|
||||
|
||||
public IEnumerable<InjectedProcess> InjectedProcesses => _injected.Values;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Eject (best-effort – EasyHook doesn't fully support unloading)
|
||||
// -----------------------------------------------------------------------
|
||||
public void Eject(int processId)
|
||||
{
|
||||
if (!_injected.TryGetValue(processId, out var entry)) return;
|
||||
entry.Shm.Dispose();
|
||||
_injected.Remove(processId);
|
||||
Log($"Ejected from [{processId}] {entry.ProcessName}");
|
||||
}
|
||||
|
||||
private static string TryGetPath(Process p)
|
||||
{
|
||||
try
|
||||
{
|
||||
return p.MainModule?.FileName ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string msg)
|
||||
{
|
||||
LogMessage?.Invoke(msg);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var e in _injected.Values) e.Shm?.Dispose();
|
||||
_injected.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
|
||||
namespace TimeMocker.UI.Core
|
||||
{
|
||||
public class PatternRule
|
||||
{
|
||||
public string Pattern { get; set; } // glob or regex
|
||||
public bool UseRegex { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
private Regex _compiled;
|
||||
|
||||
public bool IsMatch(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
|
||||
if (UseRegex)
|
||||
{
|
||||
_compiled ??= new Regex(Pattern, RegexOptions.IgnoreCase);
|
||||
return _compiled.IsMatch(path);
|
||||
}
|
||||
|
||||
// Glob: convert * and ? to regex
|
||||
var regexStr = "^" + Regex.Escape(Pattern)
|
||||
.Replace(@"\*", ".*")
|
||||
.Replace(@"\?", ".") + "$";
|
||||
return Regex.IsMatch(path, regexStr, RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public class ProcessWatcher : IDisposable
|
||||
{
|
||||
private readonly InjectionManager _injectionMgr;
|
||||
private readonly List<PatternRule> _rules = new List<PatternRule>();
|
||||
private readonly HashSet<int> _seen = new HashSet<int>();
|
||||
private Timer _timer;
|
||||
private bool _running;
|
||||
|
||||
// Current fake time settings to apply on auto-inject
|
||||
public DateTime FakeUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public event Action<string> LogMessage;
|
||||
public event Action<InjectedProcess> ProcessAutoInjected;
|
||||
|
||||
public ProcessWatcher(InjectionManager mgr)
|
||||
{
|
||||
_injectionMgr = mgr;
|
||||
}
|
||||
|
||||
public void AddRule(PatternRule rule)
|
||||
{
|
||||
lock (_rules)
|
||||
{
|
||||
_rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveRule(PatternRule rule)
|
||||
{
|
||||
lock (_rules)
|
||||
{
|
||||
_rules.Remove(rule);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearRules()
|
||||
{
|
||||
lock (_rules)
|
||||
{
|
||||
_rules.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(int pollIntervalMs = 5000)
|
||||
{
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_timer = new Timer(_ => Scan(), null, 0, pollIntervalMs);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private void Scan()
|
||||
{
|
||||
try
|
||||
{
|
||||
var processes = Process.GetProcesses();
|
||||
lock (_rules)
|
||||
{
|
||||
foreach (var p in processes)
|
||||
{
|
||||
if (_seen.Contains(p.Id)) continue;
|
||||
|
||||
var path = "";
|
||||
try
|
||||
{
|
||||
path = p.MainModule?.FileName ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
if (!rule.Enabled) continue;
|
||||
if (!rule.IsMatch(path) && !rule.IsMatch(p.ProcessName)) continue;
|
||||
|
||||
_seen.Add(p.Id);
|
||||
try
|
||||
{
|
||||
var entry = _injectionMgr.Inject(p);
|
||||
_injectionMgr.SetFakeTime(p.Id, FakeUtc);
|
||||
Log($"[AutoInject] Matched rule '{rule.Pattern}' → [{p.Id}] {p.ProcessName}");
|
||||
ProcessAutoInjected?.Invoke(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"[AutoInject] Failed on [{p.Id}] {p.ProcessName}: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* scan errors are non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string msg)
|
||||
{
|
||||
LogMessage?.Invoke(msg);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace TimeMocker.UI.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a named Memory-Mapped File so the injected hook can read
|
||||
/// the fake time without any IPC latency on the hot path.
|
||||
/// One SharedMemoryManager per injected process.
|
||||
/// </summary>
|
||||
public class SharedMemoryManager : IDisposable
|
||||
{
|
||||
public const string MmfPrefix = "TimeMocker_";
|
||||
private MemoryMappedFile _mmf;
|
||||
private MemoryMappedViewAccessor _view;
|
||||
private readonly int _size;
|
||||
private bool _disposed;
|
||||
|
||||
public string MmfName { get; }
|
||||
|
||||
public SharedMemoryManager(int processId)
|
||||
{
|
||||
MmfName = MmfPrefix + processId;
|
||||
_size = Marshal.SizeOf<MockTimeInfo>();
|
||||
_mmf = MemoryMappedFile.CreateOrOpen(MmfName, _size,
|
||||
MemoryMappedFileAccess.ReadWrite);
|
||||
_view = _mmf.CreateViewAccessor(0, _size);
|
||||
}
|
||||
|
||||
public void Write(MockTimeInfo info)
|
||||
{
|
||||
_view.Write(0, ref info);
|
||||
_view.Flush();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_view?.Dispose();
|
||||
_mmf?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MockTimeInfo
|
||||
{
|
||||
public long DeltaTicks; // Offset to add to DateTime.UtcNow.Ticks (can be negative)
|
||||
}
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Timers;
|
||||
using System.Windows.Forms;
|
||||
using TimeMocker.UI.Core;
|
||||
|
||||
namespace TimeMocker.UI.Forms
|
||||
{
|
||||
public class PatternRuleDto
|
||||
{
|
||||
public string Pattern { get; set; }
|
||||
public bool UseRegex { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class AppConfig
|
||||
{
|
||||
private static readonly string ConfigPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TimeMocker",
|
||||
"timemocker-config.json");
|
||||
|
||||
public List<PatternRuleDto> Patterns { get; set; } = new List<PatternRuleDto>();
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(ConfigPath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
File.WriteAllText(ConfigPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
return File.Exists(ConfigPath)
|
||||
? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(ConfigPath)) ?? new AppConfig()
|
||||
: new AppConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MainForm : Form
|
||||
{
|
||||
private readonly AppConfig _config;
|
||||
|
||||
private InjectionManager _injMgr;
|
||||
private ProcessWatcher _watcher;
|
||||
|
||||
// Controls
|
||||
private TabControl tabMain;
|
||||
private TabPage tabProcesses, tabPatterns, tabLog;
|
||||
|
||||
// -- Process tab
|
||||
private DataGridView dgvProcesses;
|
||||
private Button btnRefresh;
|
||||
private TextBox txtProcSearch;
|
||||
private Label lblProcSearch;
|
||||
|
||||
// -- Time panel (shared)
|
||||
private GroupBox grpTime;
|
||||
private DateTimePicker dtpDate;
|
||||
private DateTimePicker dtpTime;
|
||||
private Button btnApply;
|
||||
private Label lblPreview;
|
||||
|
||||
// -- Patterns tab
|
||||
private DataGridView dgvPatterns;
|
||||
private Button btnAddPattern, btnRemovePattern;
|
||||
private TextBox txtNewPattern;
|
||||
private RadioButton rdoGlob, rdoRegex;
|
||||
|
||||
// -- Log tab
|
||||
private RichTextBox rtbLog;
|
||||
private Button btnClearLog;
|
||||
|
||||
private System.Timers.Timer _previewUpdateTimer;
|
||||
private TimeSpan _fakeDelta = TimeSpan.Zero; // Stored offset from real time
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
_config = AppConfig.Load();
|
||||
|
||||
Text = "TimeMocker – Process Time Injection";
|
||||
Size = new Size(900, 680);
|
||||
MinimumSize = new Size(750, 560);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Font = new Font("Segoe UI", 9f);
|
||||
BackColor = Color.FromArgb(45, 52, 64);
|
||||
ForeColor = Color.FromArgb(200, 200, 200);
|
||||
|
||||
_injMgr = new InjectionManager();
|
||||
_watcher = new ProcessWatcher(_injMgr);
|
||||
|
||||
_injMgr.LogMessage += AppendLog;
|
||||
_watcher.LogMessage += AppendLog;
|
||||
_watcher.ProcessAutoInjected += entry =>
|
||||
BeginInvoke((Action)(() => RefreshProcessList()));
|
||||
|
||||
BuildUI();
|
||||
|
||||
// Load config settings
|
||||
foreach (var pattern in _config.Patterns)
|
||||
{
|
||||
_watcher.AddRule(new PatternRule
|
||||
{
|
||||
Pattern = pattern.Pattern,
|
||||
UseRegex = pattern.UseRegex,
|
||||
Enabled = pattern.Enabled
|
||||
});
|
||||
dgvPatterns.Rows.Add(pattern.Pattern, pattern.UseRegex ? "Regex" : "Glob", pattern.Enabled);
|
||||
}
|
||||
|
||||
RefreshProcessList();
|
||||
UpdateTimePreview();
|
||||
ApplyTime(); // Initialize with current time
|
||||
|
||||
// Start auto-inject watcher by default
|
||||
_watcher.FakeUtc = GetFakeTime().ToUniversalTime();
|
||||
_watcher.Start();
|
||||
AppendLog("Process watcher started.");
|
||||
|
||||
// Start preview update timer (1 second interval)
|
||||
_previewUpdateTimer = new System.Timers.Timer(1000);
|
||||
_previewUpdateTimer.Elapsed += (s, e) => BeginInvoke((Action)(UpdateTimePreview));
|
||||
_previewUpdateTimer.Start();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// UI Builder
|
||||
// =====================================================================
|
||||
private void BuildUI()
|
||||
{
|
||||
// ---- Shared time panel (top) ------------------------------------
|
||||
grpTime = new GroupBox
|
||||
{
|
||||
Text = "Mock Time Settings",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 110,
|
||||
ForeColor = Color.FromArgb(100, 160, 220),
|
||||
Padding = new Padding(8)
|
||||
};
|
||||
|
||||
var timeFlow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
AutoSize = false
|
||||
};
|
||||
|
||||
dtpDate = new DateTimePicker
|
||||
{
|
||||
Format = DateTimePickerFormat.Short,
|
||||
Width = 120,
|
||||
Height = 26,
|
||||
Value = DateTime.Now,
|
||||
Margin = new Padding(4, 10, 4, 0)
|
||||
};
|
||||
dtpDate.ValueChanged += (s, e) => UpdateTimePreview();
|
||||
|
||||
dtpTime = new DateTimePicker
|
||||
{
|
||||
Format = DateTimePickerFormat.Time,
|
||||
ShowUpDown = true,
|
||||
Width = 100,
|
||||
Height = 26,
|
||||
Value = DateTime.Now,
|
||||
Margin = new Padding(4, 10, 4, 0)
|
||||
};
|
||||
dtpTime.ValueChanged += (s, e) => UpdateTimePreview();
|
||||
|
||||
var btnSetNow = MakeButton("Now", 70, Color.FromArgb(100, 110, 120));
|
||||
btnSetNow.Margin = new Padding(4, 10, 4, 0);
|
||||
btnSetNow.Click += (s, e) =>
|
||||
{
|
||||
dtpDate.Value = dtpTime.Value = DateTime.Now;
|
||||
};
|
||||
|
||||
btnApply = MakeButton("Set", 70, Color.FromArgb(70, 140, 200));
|
||||
btnApply.Margin = new Padding(4, 10, 4, 0);
|
||||
btnApply.Click += (s, e) => ApplyTime();
|
||||
|
||||
lblPreview = new Label
|
||||
{
|
||||
AutoSize = false,
|
||||
Width = 300,
|
||||
Height = 20,
|
||||
ForeColor = Color.FromArgb(140, 150, 160),
|
||||
Font = new Font("Segoe UI", 8.5f, FontStyle.Italic),
|
||||
Margin = new Padding(4, 14, 0, 0)
|
||||
};
|
||||
|
||||
timeFlow.Controls.AddRange(new Control[]
|
||||
{
|
||||
dtpDate, dtpTime, btnSetNow, btnApply, lblPreview
|
||||
});
|
||||
grpTime.Controls.Add(timeFlow);
|
||||
|
||||
// ---- Tabs -------------------------------------------------------
|
||||
tabMain = new TabControl
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
DrawMode = TabDrawMode.OwnerDrawFixed,
|
||||
SizeMode = TabSizeMode.Fixed,
|
||||
ItemSize = new Size(120, 28)
|
||||
};
|
||||
tabMain.DrawItem += DrawTab;
|
||||
|
||||
tabProcesses = new TabPage("Processes");
|
||||
tabPatterns = new TabPage("Auto-Inject Rules");
|
||||
tabLog = new TabPage("Log");
|
||||
StyleTab(tabProcesses);
|
||||
StyleTab(tabPatterns);
|
||||
StyleTab(tabLog);
|
||||
|
||||
BuildProcessTab();
|
||||
BuildPatternsTab();
|
||||
BuildLogTab();
|
||||
|
||||
tabMain.TabPages.AddRange(new[] { tabProcesses, tabPatterns, tabLog });
|
||||
|
||||
Controls.Add(tabMain);
|
||||
Controls.Add(grpTime);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Process Tab
|
||||
// =====================================================================
|
||||
private void BuildProcessTab()
|
||||
{
|
||||
var panel = new Panel { Dock = DockStyle.Fill };
|
||||
|
||||
// Top toolbar
|
||||
var toolbar = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 40,
|
||||
Padding = new Padding(4)
|
||||
};
|
||||
|
||||
lblProcSearch = new Label { Text = "Search:", AutoSize = true, Margin = new Padding(4, 8, 2, 0) };
|
||||
txtProcSearch = new TextBox { Width = 180, Margin = new Padding(0, 6, 8, 0) };
|
||||
txtProcSearch.TextChanged += (s, e) => FilterProcessList();
|
||||
|
||||
btnRefresh = MakeButton("⟳ Refresh", 90, Color.FromArgb(100, 110, 120));
|
||||
btnRefresh.Margin = new Padding(0, 6, 4, 0);
|
||||
btnRefresh.Click += (s, e) => RefreshProcessList();
|
||||
|
||||
toolbar.Controls.AddRange(new Control[] { lblProcSearch, txtProcSearch, btnRefresh });
|
||||
toolbar.BackColor = Color.FromArgb(55, 62, 74);
|
||||
|
||||
// Single process list with Inject checkbox
|
||||
var lblSection = MakeSectionLabel("Processes");
|
||||
dgvProcesses = MakeGrid();
|
||||
dgvProcesses.ReadOnly = false;
|
||||
dgvProcesses.MultiSelect = false;
|
||||
dgvProcesses.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None;
|
||||
dgvProcesses.Columns.AddRange(
|
||||
Col("PID", 50), Col("Name", 160), Col("Path", 380), BoolCol("Injected", 70));
|
||||
|
||||
// Make all columns read-only except the checkbox
|
||||
foreach (DataGridViewColumn col in dgvProcesses.Columns)
|
||||
{
|
||||
if (col.Name != "Injected")
|
||||
col.ReadOnly = true;
|
||||
}
|
||||
|
||||
dgvProcesses.CellValueChanged += dgvProcesses_CellValueChanged;
|
||||
dgvProcesses.CurrentCellDirtyStateChanged += (s, e) =>
|
||||
{
|
||||
if (dgvProcesses.IsCurrentCellDirty)
|
||||
{
|
||||
dgvProcesses.CommitEdit(DataGridViewDataErrorContexts.Commit);
|
||||
}
|
||||
};
|
||||
|
||||
panel.Controls.Add(lblSection);
|
||||
panel.Controls.Add(dgvProcesses);
|
||||
panel.Controls.Add(toolbar);
|
||||
|
||||
tabProcesses.Controls.Add(panel);
|
||||
}
|
||||
|
||||
private List<ProcessRow> _allRows = new List<ProcessRow>();
|
||||
|
||||
private class ProcessRow
|
||||
{
|
||||
public int Id;
|
||||
public string Name, Path;
|
||||
public bool Injected;
|
||||
}
|
||||
|
||||
private void dgvProcesses_CellValueChanged(object sender, DataGridViewCellEventArgs e)
|
||||
{
|
||||
if (e.RowIndex < 0 || e.ColumnIndex < 0) return;
|
||||
if (dgvProcesses.Columns[e.ColumnIndex].Name != "Injected") return;
|
||||
|
||||
var pidCell = dgvProcesses.Rows[e.RowIndex].Cells[0];
|
||||
if (pidCell.Value == null) return;
|
||||
|
||||
var pid = (int)pidCell.Value;
|
||||
var shouldInject = (bool)dgvProcesses.Rows[e.RowIndex].Cells[e.ColumnIndex].Value;
|
||||
|
||||
if (shouldInject)
|
||||
{
|
||||
// Inject the process
|
||||
if (!_injMgr.IsInjected(pid))
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Process.GetProcessById(pid);
|
||||
_injMgr.Inject(p);
|
||||
var dt = GetFakeTime();
|
||||
_injMgr.SetFakeTime(p.Id, dt.ToUniversalTime());
|
||||
AppendLog($"Injected into [{pid}] {p.ProcessName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Injection failed:\n{ex.Message}", "Error",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
// Revert checkbox on failure
|
||||
dgvProcesses.BeginInvoke((Action)(() =>
|
||||
{
|
||||
dgvProcesses.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = false;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Eject the process
|
||||
if (_injMgr.IsInjected(pid))
|
||||
{
|
||||
_injMgr.Eject(pid);
|
||||
AppendLog($"Ejected from [{pid}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshProcessList()
|
||||
{
|
||||
_allRows.Clear();
|
||||
|
||||
// Get current live process IDs
|
||||
var livePids = new HashSet<int>(Process.GetProcesses().Select(p => p.Id));
|
||||
|
||||
// Remove dead processes from injection manager
|
||||
var deadPids = _injMgr.InjectedProcesses
|
||||
.Select(x => x.ProcessId)
|
||||
.Where(pid => !livePids.Contains(pid))
|
||||
.ToList();
|
||||
|
||||
foreach (var deadPid in deadPids)
|
||||
{
|
||||
_injMgr.Eject(deadPid);
|
||||
}
|
||||
|
||||
var injectedPids = new HashSet<int>(_injMgr.InjectedProcesses.Select(x => x.ProcessId));
|
||||
|
||||
foreach (var p in Process.GetProcesses().OrderBy(x => x.ProcessName))
|
||||
{
|
||||
var path = "";
|
||||
try
|
||||
{
|
||||
path = p.MainModule?.FileName ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip processes we can't access
|
||||
continue;
|
||||
}
|
||||
|
||||
_allRows.Add(new ProcessRow
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.ProcessName,
|
||||
Path = path,
|
||||
Injected = injectedPids.Contains(p.Id)
|
||||
});
|
||||
}
|
||||
|
||||
FilterProcessList();
|
||||
}
|
||||
|
||||
private void FilterProcessList()
|
||||
{
|
||||
var q = txtProcSearch.Text.Trim().ToLower();
|
||||
dgvProcesses.SuspendLayout();
|
||||
dgvProcesses.Rows.Clear();
|
||||
foreach (var r in _allRows)
|
||||
{
|
||||
if (q.Length > 0 && !r.Name.ToLower().Contains(q) && !r.Path.ToLower().Contains(q)) continue;
|
||||
dgvProcesses.Rows.Add(r.Id, r.Name, r.Path, r.Injected);
|
||||
}
|
||||
dgvProcesses.ResumeLayout();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Patterns Tab
|
||||
// =====================================================================
|
||||
// Patterns Tab
|
||||
// =====================================================================
|
||||
private void BuildPatternsTab()
|
||||
{
|
||||
var panel = new Panel { Dock = DockStyle.Fill };
|
||||
|
||||
var toolbar = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 80,
|
||||
Padding = new Padding(4),
|
||||
BackColor = Color.FromArgb(55, 62, 74)
|
||||
};
|
||||
|
||||
// Pattern input row
|
||||
var lblNew = new Label { Text = "Pattern:", AutoSize = true, Margin = new Padding(4, 12, 4, 0) };
|
||||
txtNewPattern = new TextBox
|
||||
{ Width = 280, Margin = new Padding(0, 10, 4, 0), Text = "e.g. C:\\Games\\MyGame\\* or ^.*chrome.*$" };
|
||||
|
||||
rdoGlob = new RadioButton
|
||||
{
|
||||
Text = "Glob", Checked = true, AutoSize = true, Margin = new Padding(4, 12, 4, 0),
|
||||
ForeColor = Color.FromArgb(200, 200, 200)
|
||||
};
|
||||
rdoRegex = new RadioButton
|
||||
{
|
||||
Text = "Regex", AutoSize = true, Margin = new Padding(4, 12, 4, 0),
|
||||
ForeColor = Color.FromArgb(200, 200, 200)
|
||||
};
|
||||
|
||||
btnAddPattern = MakeButton("+ Add Rule", 100, Color.FromArgb(80, 160, 100));
|
||||
btnAddPattern.Margin = new Padding(8, 8, 4, 0);
|
||||
btnAddPattern.Click += OnAddPattern;
|
||||
|
||||
btnRemovePattern = MakeButton("✕ Remove", 90, Color.FromArgb(190, 90, 90));
|
||||
btnRemovePattern.Margin = new Padding(4, 8, 4, 0);
|
||||
btnRemovePattern.Click += OnRemovePattern;
|
||||
|
||||
toolbar.Controls.AddRange(new Control[]
|
||||
{
|
||||
lblNew, txtNewPattern, rdoGlob, rdoRegex,
|
||||
btnAddPattern, btnRemovePattern
|
||||
});
|
||||
|
||||
var lblSection = MakeSectionLabel("Auto-Inject Rules (process path or name must match)");
|
||||
|
||||
dgvPatterns = MakeGrid();
|
||||
dgvPatterns.Columns.AddRange(
|
||||
Col("Pattern", 320), Col("Type", 60), BoolCol("Enabled"));
|
||||
|
||||
panel.Controls.Add(dgvPatterns);
|
||||
panel.Controls.Add(lblSection);
|
||||
panel.Controls.Add(toolbar);
|
||||
|
||||
tabPatterns.Controls.Add(panel);
|
||||
}
|
||||
|
||||
private void OnAddPattern(object sender, EventArgs e)
|
||||
{
|
||||
var pat = txtNewPattern.Text.Trim();
|
||||
if (string.IsNullOrEmpty(pat)) return;
|
||||
|
||||
var rule = new PatternRule
|
||||
{
|
||||
Pattern = pat,
|
||||
UseRegex = rdoRegex.Checked,
|
||||
Enabled = true
|
||||
};
|
||||
_watcher.AddRule(rule);
|
||||
dgvPatterns.Rows.Add(pat, rule.UseRegex ? "Regex" : "Glob", true);
|
||||
txtNewPattern.Clear();
|
||||
}
|
||||
|
||||
private void OnRemovePattern(object sender, EventArgs e)
|
||||
{
|
||||
if (dgvPatterns.SelectedRows.Count == 0) return;
|
||||
var idx = dgvPatterns.SelectedRows[0].Index;
|
||||
var pat = dgvPatterns.Rows[idx].Cells[0].Value?.ToString();
|
||||
_watcher.ClearRules();
|
||||
dgvPatterns.Rows.RemoveAt(idx);
|
||||
// Re-add remaining
|
||||
foreach (DataGridViewRow row in dgvPatterns.Rows)
|
||||
_watcher.AddRule(new PatternRule
|
||||
{
|
||||
Pattern = row.Cells[0].Value?.ToString() ?? "",
|
||||
UseRegex = row.Cells[1].Value?.ToString() == "Regex",
|
||||
Enabled = (bool)(row.Cells[2].Value ?? true)
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Log Tab
|
||||
// =====================================================================
|
||||
private void BuildLogTab()
|
||||
{
|
||||
rtbLog = new RichTextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = Color.FromArgb(30, 35, 42),
|
||||
ForeColor = Color.FromArgb(170, 210, 150),
|
||||
Font = new Font("Consolas", 9f),
|
||||
ReadOnly = true,
|
||||
ScrollBars = RichTextBoxScrollBars.Vertical
|
||||
};
|
||||
|
||||
btnClearLog = MakeButton("Clear", 70, Color.FromArgb(100, 110, 120));
|
||||
btnClearLog.Dock = DockStyle.Bottom;
|
||||
btnClearLog.Click += (s, e) => rtbLog.Clear();
|
||||
|
||||
tabLog.Controls.Add(rtbLog);
|
||||
tabLog.Controls.Add(btnClearLog);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Time Logic
|
||||
// =====================================================================
|
||||
private DateTime GetFakeTime()
|
||||
{
|
||||
// Return current real time + stored delta (time advances naturally)
|
||||
return DateTime.Now + _fakeDelta;
|
||||
}
|
||||
|
||||
private void ApplyTime()
|
||||
{
|
||||
// Calculate the desired time from the picker
|
||||
var desiredTime = dtpDate.Value.Date + dtpTime.Value.TimeOfDay;
|
||||
|
||||
// Store the delta (offset from real time)
|
||||
_fakeDelta = desiredTime - DateTime.Now;
|
||||
|
||||
// Apply to all injected processes
|
||||
var dt = GetFakeTime().ToUniversalTime();
|
||||
_injMgr.SetFakeTimeAll(dt);
|
||||
_watcher.FakeUtc = dt;
|
||||
|
||||
UpdateTimePreview();
|
||||
}
|
||||
|
||||
private void UpdateTimePreview()
|
||||
{
|
||||
var dt = GetFakeTime();
|
||||
|
||||
// Use the stored delta (constant offset)
|
||||
string deltaStr = FormatDelta(_fakeDelta);
|
||||
lblPreview.Text = $"Fake: {dt:yyyy-MM-dd HH:mm:ss} (local, offset {deltaStr})";
|
||||
}
|
||||
|
||||
private static string FormatDelta(TimeSpan delta)
|
||||
{
|
||||
bool isNegative = delta.TotalSeconds < 0;
|
||||
var absDelta = delta.Duration();
|
||||
|
||||
var parts = new List<string>();
|
||||
|
||||
// Days
|
||||
if (absDelta.Days > 0)
|
||||
parts.Add($"{absDelta.Days}d");
|
||||
|
||||
// Hours
|
||||
if (absDelta.Hours > 0)
|
||||
parts.Add($"{absDelta.Hours}h");
|
||||
|
||||
// Minutes
|
||||
if (absDelta.Minutes > 0)
|
||||
parts.Add($"{absDelta.Minutes}m");
|
||||
|
||||
// Seconds (always show if no other units, or if under a minute)
|
||||
if (parts.Count == 0 || absDelta.Seconds > 0)
|
||||
parts.Add($"{absDelta.Seconds}s");
|
||||
|
||||
var result = string.Join("", parts);
|
||||
return isNegative ? $"-{result}" : $"+{result}";
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers
|
||||
// =====================================================================
|
||||
private void AppendLog(string msg)
|
||||
{
|
||||
if (rtbLog == null) return;
|
||||
if (rtbLog.InvokeRequired)
|
||||
{
|
||||
rtbLog.BeginInvoke((Action)(() => AppendLog(msg)));
|
||||
return;
|
||||
}
|
||||
|
||||
rtbLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}\n");
|
||||
rtbLog.ScrollToCaret();
|
||||
}
|
||||
|
||||
private static DataGridView MakeGrid()
|
||||
{
|
||||
var g = new DataGridView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
MultiSelect = false,
|
||||
BackgroundColor = Color.FromArgb(52, 58, 68),
|
||||
ForeColor = Color.FromArgb(175, 180, 185),
|
||||
GridColor = Color.FromArgb(85, 92, 102),
|
||||
BorderStyle = BorderStyle.None,
|
||||
RowHeadersVisible = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
|
||||
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
|
||||
ColumnHeadersHeight = 28
|
||||
};
|
||||
g.EnableHeadersVisualStyles = false;
|
||||
g.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(72, 78, 88);
|
||||
g.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(160, 185, 210);
|
||||
g.DefaultCellStyle.SelectionBackColor = Color.FromArgb(98, 138, 178);
|
||||
g.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(58, 64, 74);
|
||||
return g;
|
||||
}
|
||||
|
||||
private static DataGridViewTextBoxColumn Col(string name, int w)
|
||||
{
|
||||
return new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = name,
|
||||
HeaderText = name,
|
||||
Width = w,
|
||||
MinimumWidth = w,
|
||||
SortMode = DataGridViewColumnSortMode.NotSortable
|
||||
};
|
||||
}
|
||||
|
||||
private static DataGridViewCheckBoxColumn BoolCol(string name, int width = 65)
|
||||
{
|
||||
return new DataGridViewCheckBoxColumn
|
||||
{
|
||||
Name = name,
|
||||
HeaderText = name,
|
||||
Width = width,
|
||||
MinimumWidth = width
|
||||
};
|
||||
}
|
||||
|
||||
private static Button MakeButton(string text, int w, Color bg)
|
||||
{
|
||||
return new Button
|
||||
{
|
||||
Text = text,
|
||||
Width = w,
|
||||
Height = 26,
|
||||
BackColor = bg,
|
||||
ForeColor = Color.White,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Cursor = Cursors.Hand
|
||||
};
|
||||
}
|
||||
|
||||
private static Label MakeSectionLabel(string text)
|
||||
{
|
||||
return new Label
|
||||
{
|
||||
Text = text,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 22,
|
||||
ForeColor = Color.FromArgb(150, 180, 220),
|
||||
Font = new Font("Segoe UI", 8.5f, FontStyle.Bold),
|
||||
Padding = new Padding(4, 2, 0, 0),
|
||||
BackColor = Color.FromArgb(50, 57, 69)
|
||||
};
|
||||
}
|
||||
|
||||
private static void StyleTab(TabPage tab)
|
||||
{
|
||||
tab.BackColor = Color.FromArgb(45, 52, 64);
|
||||
tab.ForeColor = Color.FromArgb(200, 200, 200);
|
||||
}
|
||||
|
||||
private void DrawTab(object sender, DrawItemEventArgs e)
|
||||
{
|
||||
var tab = (TabControl)sender;
|
||||
var page = tab.TabPages[e.Index];
|
||||
var rect = e.Bounds;
|
||||
var selected = e.Index == tab.SelectedIndex;
|
||||
|
||||
using var bg = new SolidBrush(selected ? Color.FromArgb(80, 130, 180) : Color.FromArgb(60, 68, 80));
|
||||
e.Graphics.FillRectangle(bg, rect);
|
||||
|
||||
var sf = new StringFormat
|
||||
{ Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
|
||||
using var fg = new SolidBrush(selected ? Color.White : Color.FromArgb(170, 180, 190));
|
||||
e.Graphics.DrawString(page.Text, Font, fg, rect, sf);
|
||||
}
|
||||
|
||||
private static void ShowInfo(string msg)
|
||||
{
|
||||
MessageBox.Show(msg, "Info", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
// Stop preview update timer
|
||||
_previewUpdateTimer?.Stop();
|
||||
_previewUpdateTimer?.Dispose();
|
||||
|
||||
// Save config
|
||||
_config.Patterns.Clear();
|
||||
foreach (DataGridViewRow row in dgvPatterns.Rows)
|
||||
{
|
||||
_config.Patterns.Add(new PatternRuleDto
|
||||
{
|
||||
Pattern = row.Cells[0].Value?.ToString() ?? "",
|
||||
UseRegex = row.Cells[1].Value?.ToString() == "Regex",
|
||||
Enabled = (bool)(row.Cells[2].Value ?? true)
|
||||
});
|
||||
}
|
||||
_config.Save();
|
||||
|
||||
_watcher.Stop();
|
||||
_injMgr.Dispose();
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using TimeMocker.UI.Forms;
|
||||
|
||||
namespace TimeMocker.UI
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
// EasyHook requires elevated privileges for cross-process injection
|
||||
if (!IsElevated())
|
||||
{
|
||||
var result = MessageBox.Show(
|
||||
"TimeMocker needs to run as Administrator to inject into other processes.\n\n" +
|
||||
"Please restart as Administrator.",
|
||||
"Elevation Required",
|
||||
MessageBoxButtons.OKCancel,
|
||||
MessageBoxIcon.Warning);
|
||||
|
||||
if (result == DialogResult.OK)
|
||||
RestartAsAdmin();
|
||||
return;
|
||||
}
|
||||
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
|
||||
private static bool IsElevated()
|
||||
{
|
||||
using var id = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(id);
|
||||
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static void RestartAsAdmin()
|
||||
{
|
||||
var info = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = Application.ExecutablePath,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas"
|
||||
};
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(info);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* user cancelled UAC */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>TimeMocker</AssemblyName>
|
||||
<RootNamespace>TimeMocker.UI</RootNamespace>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<LangVersion>8</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EasyHook" Version="2.7.7097.0"/>
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5"/>
|
||||
</ItemGroup>
|
||||
<!-- Build both x86 and x64 versions of the hook DLL and copy with architecture suffix -->
|
||||
<Target Name="BuildAllHookDlls" AfterTargets="Build" DependsOnTargets="_BuildX64Hook;_BuildX86Hook;_CopyHookDlls">
|
||||
</Target>
|
||||
<Target Name="_BuildX64Hook">
|
||||
<MSBuild Projects="$(SolutionDir)TimeMocker.Hook\TimeMocker.Hook.csproj"
|
||||
Properties="Platform=x64;Configuration=$(Configuration)"
|
||||
Targets="Build"
|
||||
RunEachTargetSeparately="true" />
|
||||
</Target>
|
||||
<Target Name="_BuildX86Hook">
|
||||
<MSBuild Projects="$(SolutionDir)TimeMocker.Hook\TimeMocker.Hook.csproj"
|
||||
Properties="Platform=x86;Configuration=$(Configuration)"
|
||||
Targets="Build"
|
||||
RunEachTargetSeparately="true" />
|
||||
</Target>
|
||||
<Target Name="_CopyHookDlls" DependsOnTargets="_BuildX64Hook;_BuildX86Hook">
|
||||
<PropertyGroup>
|
||||
<HookBuildDir>$(SolutionDir)TimeMocker.Hook\bin\</HookBuildDir>
|
||||
<X64DllPath>$(HookBuildDir)x64\$(Configuration)\net48\TimeMocker.Hook.dll</X64DllPath>
|
||||
<X86DllPath>$(HookBuildDir)x86\$(Configuration)\net48\TimeMocker.Hook.dll</X86DllPath>
|
||||
<X64Dest>$(OutputPath)TimeMocker.Hook.x64.dll</X64Dest>
|
||||
<X86Dest>$(OutputPath)TimeMocker.Hook.x86.dll</X86Dest>
|
||||
</PropertyGroup>
|
||||
<!-- Copy x64 version with x64 suffix -->
|
||||
<Copy SourceFiles="$(X64DllPath)"
|
||||
DestinationFiles="$(X64Dest)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<!-- Copy x86 version with x86 suffix -->
|
||||
<Copy SourceFiles="$(X86DllPath)"
|
||||
DestinationFiles="$(X86Dest)"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<!-- Clean the renamed DLLs as well -->
|
||||
<Target Name="CleanRenamedHookDlls" AfterTargets="Clean">
|
||||
<Delete Files="$(OutputPath)TimeMocker.Hook.x64.dll" TreatErrorsAsWarnings="true" />
|
||||
<Delete Files="$(OutputPath)TimeMocker.Hook.x86.dll" TreatErrorsAsWarnings="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TimeMocker.UI", "TimeMocker.UI\TimeMocker.UI.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TimeMocker.Hook", "TimeMocker.Hook\TimeMocker.Hook.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Release|x64 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|x64
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|x64
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|x64
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|x64
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|x64
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestMajor",
|
||||
"allowPrerelease": true
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,35 @@
|
||||
# time-mocker-target
|
||||
|
||||
Java test target for [time-mocker](https://github.com/tiennm99/time-mocker) — validates injected fake time behavior.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
./gradlew run
|
||||
```
|
||||
|
||||
Run alongside `time-mocker` to verify that system time injection works correctly.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
**Without time-mocker** — prints actual current time, ticking every second:
|
||||
|
||||
```
|
||||
2025-01-15T08:30:01
|
||||
2025-01-15T08:30:02
|
||||
2025-01-15T08:30:03
|
||||
```
|
||||
|
||||
**With time-mocker injecting a fake date** (e.g. 2000-06-15T12:00:00) — the same binary prints the mocked time instead:
|
||||
|
||||
```
|
||||
2000-06-15T12:00:01
|
||||
2000-06-15T12:00:02
|
||||
2000-06-15T12:00:03
|
||||
```
|
||||
|
||||
If the output reflects the injected date rather than real system time, the injection is working correctly.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](LICENSE).
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -1,2 +0,0 @@
|
||||
# time-mocker-target
|
||||
Java application target to test [time-mocker](https://github.com/tiennm99/time-mocker)
|
||||
Reference in new issue
Block a user