30 Commits
Author SHA1 Message Date
tiennm99 1da4af498b chore: add push/PR CI, drop unused windows dep, refresh docs
Add a CI workflow running fmt, clippy with warnings denied, and the test
suite on every push and pull request. Remove the unused workspace windows
dependency now that only windows-sys is used. Update the README architecture
name and Java harness reference, clarify the plans gitignore comment, and
carry the code-reviewer agent memory.
2026-09-21 16:14:04 +07:00
tiennm99 ffab962360 fix(ui): detect PID reuse, verify delta channel, correct rule and date handling
Hold one process handle across every identity check and injection so the PID
cannot be recycled mid-inject, and prune injected entries whose start time no
longer matches. Read the delta back before reporting a successful injection
so a dead channel is no longer a silent no-op mock. Match rules
case-insensitively and skip the path arm when the path is empty, surface
invalid patterns instead of swallowing them, and stop retrying a failed
auto-inject target until its PID is reused. Restore the last fake time on
startup, reset "Now" to exactly zero delta, clamp the whole date to range so
a boundary shift no longer jumps a year, and default missing persisted fields
so one bad field cannot wipe all rules. Show a message box on release startup
failure, and cache compiled rules between scans.
2026-09-21 16:13:27 +07:00
tiennm99 f4bd35ae5b fix(hook): harden detour install, unload, and delta reconnection
Initialize every detour before enabling any, so a hook body that calls
another detour's trampoline can never hit an uninitialized detour and abort
the process. Pin the module on load so unloading can no longer leave
kernel32/ntdll patched to jump into freed memory. On a failed FILETIME
conversion, fall through to the real time instead of returning an
uninitialized SYSTEMTIME. Reconnect the shared delta mapping lazily so
setting a delta after injection takes effect instead of being disabled
forever. Add host-agnostic tick-conversion tests.
2026-09-21 16:13:17 +07:00
tiennm99 a2e34060e0 docs(readme): drop link to retired time-mocker-target repo
The standalone repo was deleted after its content and history were
merged here; the Java test target lives in-repo only now.
2026-08-05 21:09:39 +07:00
tiennm99 78edb78649 Remove C# and C++ port trees; update README references
The earlier C# (EasyHook) and C++ (MS Detours) ports were merged into
this repo under csharp/ and cpp/ so their full histories stay reachable
(git log -- csharp/ cpp/). Their trees are removed from HEAD because
their IPC tick epoch (delta against DateTime.UtcNow.Ticks) is
incompatible with the Rust implementation's raw FILETIME contract —
keeping both in the working tree would present two contradictory IPC
designs side by side.

The Java test target remains in the working tree; README links now
point at in-repo paths instead of the retired port repositories.
2026-08-05 20:46:39 +07:00
tiennm99 e863efc98f feat(ui): keyboard apply/cancel + larger day-button hit area
Close two LOCKED spec deviations from design-decisions.md flagged by
code-review of 18020e2:

- Q5: Enter key inside the popup now triggers Apply (apply_fake_time +
  close), Esc cancels without committing. Both checks use consume_key
  AFTER the inner widgets have rendered so a focused DragValue keeps
  its own commit-on-Enter / cancel-on-Esc semantics.

- "Hit-area sizing" decision: day-grid buttons bumped from 28x22 px
  (slightly cramped) to 32x32 px (spec minimum). Popup still fits in
  the 260..=320 px min/max width band.

No public API change. apply_fake_time, picked_naive_dt, and the
DST/overflow status_msg flow are untouched.
2026-05-21 21:50:33 +07:00
tiennm99 5bb4a68082 feat(ui): combined date+time popup for Mock Time bar
Replace the six DragValue spinboxes (year/month/day/hour/minute/second)
with a single datetime button that opens one popup containing:

- month-navigable calendar grid (chevrons clamp at MIN_YEAR-01 / MAX_YEAR-12)
- 6x7 day grid via chrono::NaiveDate arithmetic; out-of-month cells dimmed
  and jump-to-month on click; today gets a 1px outline; selected day filled
- HH:MM:SS DragValues for time (same control style as before)
- quick-select row: Now / Midnight / Noon / -1d / +1d
- right-aligned Apply primary action; Esc and click-outside close without
  committing (CloseOnClickOutside + manual Esc capture)

Top bar reduces to [Mock Time] [datetime ▼] [Now]   Δ = +X.Xs. The Set
button is gone — Apply (inside the popup) is the only commit path now.

picker_view_year/month is decoupled from fake_*, so chevroning months
does not move the selection; opening the popup re-syncs the view to the
current fake_year/month.

Drop the now-dead pub(crate) fn days_in_month and its 10 tests — the new
calendar grid uses chrono's Duration::days and cell_date.day() directly,
both of which are self-clamping. apply_fake_time, picked_naive_dt,
unix_micros_to_filetime_ticks, and the DST/overflow status_msg flow are
unchanged.
2026-05-21 21:44:58 +07:00
tiennm99 79d3b69dcd fix(mmf): fall back to Local\ namespace when controller is not elevated
Creating a `Global\TimeMocker_<pid>` mapping requires
`SeCreateGlobalPrivilege`, which an unelevated token (e.g. debug `cargo run`
where the UAC manifest is intentionally skipped) does not have, so
`CreateFileMappingW` returned NULL with `ERROR_ACCESS_DENIED` and the
auto-inject scanner spammed `create MMF Global\TimeMocker_<pid>` for every
enumerated PID.

The UI now tries `Global\` first and falls back to `Local\` on access
denied, with a one-shot log warning so the auto-inject loop doesn't
flood. The hook DLL probes both names on attach so the IPC pairs up
symmetrically. Release builds keep their elevated `Global\` cross-session
reach; debug builds work against same-session targets without admin.

Also adds a dedicated `time-mocker-test-target` crate — a small console
binary that prints all 5 hooked time APIs every second with its own PID
banner, so dev verification can inject into a controlled harness instead
of arbitrary running processes.
2026-05-20 21:46:31 +07:00
tiennm99 438d11fde7 ci: add release workflow for Windows x64 artifacts
Build and publish a GitHub Release on each pushed v* tag (also runs on
workflow_dispatch with a required tag input). The job runs on
windows-latest, tests + clippies + release-builds the workspace via
the pinned nightly toolchain, then zips time_mocker_ui.exe +
time_mocker_hook.dll + LICENSE + README into
time-mocker-<tag>-windows-x64.zip with a sha256 sidecar.
2026-05-20 17:08:52 +07:00
tiennm99 461298b453 refactor: harden IPC, hook install, and injection safety
Workspace-wide refactor that fixes correctness, safety, and concurrency
issues across the three crates while preserving the public CLI/UX surface.

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

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

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

Total: 56/56 tests passing, cargo clippy clean, cargo build --release
produces time_mocker_ui.exe and time_mocker_hook.dll.
2026-05-20 17:08:43 +07:00
tiennm99 52a01be2e4 chore: relicense under Apache-2.0
Switch from MIT to Apache-2.0 across LICENSE, workspace manifest, and README.
2026-05-20 17:08:09 +07:00
tiennm99 4d3c35a126 docs: drop "(Rust)" suffix; promote to canonical time-mocker
Repo renamed from tiennm99/time-mocker-rs to tiennm99/time-mocker.
README and workspace Cargo.toml updated to reflect the new canonical
identity; C# and C++ ports linked as language siblings.
2026-05-20 15:43:42 +07:00
tiennm99 b93c17d2bb docs: note rename to time-mocker-csharp; link canonical Rust impl
Repo renamed from tiennm99/time-mocker to tiennm99/time-mocker-csharp.
The canonical TimeMocker project is now the Rust implementation at
tiennm99/time-mocker.
2026-05-20 15:43:31 +07:00
tiennm99 001cf53c37 feat: initial Rust port of time-mocker
Rust workspace with three crates:
- time-mocker-core: shared MockTimeInfo + named MMF helper + FILETIME helpers
- time-mocker-hook: cdylib injected into targets; inline detours via retour
  on GetSystemTime / GetLocalTime / GetSystemTimeAsFileTime /
  GetSystemTimePreciseAsFileTime / NtQuerySystemTime
- time-mocker-ui: egui controller with process list, time picker, and
  glob/regex auto-inject rules; injects via dll-syringe; UAC manifest
  embedded in release builds

IPC: 8-byte MMF named TimeMocker_<pid> holding an i64 delta in FILETIME
ticks. Hook adds delta to the real FILETIME on every call. Note this
differs from the C# version's .NET-tick delta -- contracts are not
interoperable.

Requires nightly toolchain (retour uses unboxed_closures / tuple_trait);
pinned via rust-toolchain.toml.
2026-05-20 14:59:36 +07:00
tiennm99 581785d90e docs(readme): add expected output demo with and without time-mocker 2026-05-11 21:42:27 +07:00
tiennm99 de34a948f0 docs: flesh out README 2026-05-11 20:15:41 +07:00
tiennm99 e6b31272a6 docs: add Related section linking sibling repos 2026-05-11 17:59:28 +07:00
tiennm99 194a1179bb docs: add Related section linking sibling repos 2026-05-11 17:59:27 +07:00
tiennm99 695bfde6f2 feat: 2nd version 2026-02-27 17:31:56 +07:00
tiennm99 f4f8baa1e6 chore: init first version 2026-02-27 17:30:37 +07:00
Tien Nguyen Minh a6b5eb7606 Initial commit 2026-02-27 17:24:29 +07:00
tiennm99 fc1ba194d1 feat: cleanup 2026-02-27 16:50:21 +07:00
tiennm99 46c13d0533 Update global.json 2026-02-27 16:37:05 +07:00
tiennm99 06e195c5a2 Revert "Update release.yml"
This reverts commit d89ad156c8b68edf147bda50103867ae8c86df73.
2026-02-27 16:20:20 +07:00
tiennm99 9c71d84861 Update release.yml 2026-02-27 16:11:00 +07:00
tiennm99 a9a76fb541 Update InjectionManager.cs 2026-02-27 15:56:25 +07:00
tiennm99 add9912ddd Update release.yml 2026-02-27 15:35:08 +07:00
tiennm99 d394f6f589 Revert "feat: update actions"
This reverts commit ad9624f1d72b1c75b65a3c7876ef75d3171df874.
2026-02-27 15:29:26 +07:00
tiennm99 b2329b68c9 feat: update actions 2026-02-27 15:17:21 +07:00
tiennm99 0dc883eb6d Update release.yml 2026-02-27 14:49:15 +07:00
55 changed files with 7383 additions and 1832 deletions

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]]
+27
View File
@@ -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
+88
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -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
View File
@@ -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.
+93
View File
@@ -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
+21
View File
@@ -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",
] }
+48
View File
@@ -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}")
}
+292
View File
@@ -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);
}
}
+189
View File
@@ -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());
}
}
+22
View File
@@ -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);
+29
View File
@@ -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",
] }
+121
View File
@@ -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()) };
}
+359
View File
@@ -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
}
+8
View File
@@ -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;
+22
View File
@@ -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",
] }
+156
View File
@@ -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))
}
}
+48
View File
@@ -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"
+23
View File
@@ -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"
);
}
}
+65
View File
@@ -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()
}
}
+233
View File
@@ -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"
);
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)"
]
}
}
-45
View File
@@ -1,45 +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@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Build Solution
run: dotnet build TimeMocker.sln -c Release -p:Platform=x64
- name: Prepare Release Artifacts
run: |
mkdir release
# Copy all DLLs from the build output
copy TimeMocker.UI\bin\x64\Release\net48\*.dll release\
copy TimeMocker.UI\bin\x64\Release\net48\*.exe release\
copy TimeMocker.UI\bin\x64\Release\net48\*.config 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 }}
-59
View File
@@ -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
-141
View File
@@ -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.
-115
View File
@@ -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,177 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
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");
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool IsWow64Process(IntPtr hProcess, out bool wow64Process);
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 });
// Determine which DLL to use based on target process architecture
string hookDllPath = GetHookDllPath(process);
RemoteHooking.Inject(
process.Id,
InjectionOptions.DoNotRequireStrongName,
hookDllPath,
hookDllPath,
entry.Shm.MmfName);
entry.IsInjected = true;
_injected[process.Id] = entry;
Log($"Injected into [{process.Id}] {process.ProcessName} ({GetArchitectureName(process)})");
}
catch (Exception ex)
{
entry.Shm.Dispose();
Log($"Failed to inject into [{process.Id}] {process.ProcessName}: {ex.Message}");
throw;
}
return entry;
}
private static string GetHookDllPath(Process process)
{
bool is64BitTarget = Is64BitProcess(process);
if (is64BitTarget)
{
if (!File.Exists(HookDllPathX64))
throw new FileNotFoundException($"x64 hook DLL not found: {HookDllPathX64}");
return HookDllPathX64;
}
else
{
if (!File.Exists(HookDllPathX86))
throw new FileNotFoundException($"x86 hook DLL not found: {HookDllPathX86}");
return HookDllPathX86;
}
}
private static bool Is64BitProcess(Process process)
{
if (!Environment.Is64BitOperatingSystem)
return false;
bool isWow64;
if (!IsWow64Process(process.Handle, out isWow64))
return false;
// If IsWow64Process returns false, it's a 64-bit process
// If it returns true, it's a 32-bit process running on 64-bit Windows
return !isWow64;
}
private static string GetArchitectureName(Process process)
{
return Is64BitProcess(process) ? "x64" : "x86";
}
// -----------------------------------------------------------------------
// 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();
}
}
}
-152
View File
@@ -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)
}
}
-726
View File
@@ -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);
}
}
}
-58
View File
@@ -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 */
}
}
}
}
-53
View File
@@ -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>
-25
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
{
"sdk": {
"version": "10.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}
}
File renamed without changes.
File renamed without changes.
+35
View File
@@ -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.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy"]
profile = "minimal"
-2
View File
@@ -1,2 +0,0 @@
# time-mocker-target
Java application target to test [time-mocker](https://github.com/tiennm99/time-mocker)