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.
This commit is contained in:
tiennm99 committed 2026-09-21 16:13:27 +07:00
1 parent f4bd35ae5b
commit ffab962360
10 files changed
+870 -194

No files matched your search

Generated
+1
View File
@@ -2466,6 +2466,7 @@ dependencies = [
"embed-manifest",
"globset",
"regex",
"ron",
"serde",
"sysinfo 0.32.1",
"tempfile",
+10 -5
View File
@@ -41,9 +41,8 @@ fn main() {
// 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_as_file_time =
unsafe { resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimeAsFileTime") };
let get_system_time_precise_as_file_time = unsafe {
resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
};
@@ -68,12 +67,18 @@ fn main() {
// 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));
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));
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 {
+5
View File
@@ -34,6 +34,7 @@ windows-sys = { workspace = true, features = [
"Win32_System_Threading",
"Win32_Security",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[build-dependencies]
@@ -41,3 +42,7 @@ 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"
+11 -9
View File
@@ -2,18 +2,20 @@
//! 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).
println!("cargo:rerun-if-changed=build.rs");
let profile = std::env::var("PROFILE").unwrap_or_default();
if profile != "release" {
return;
}
#[cfg(windows)]
{
use embed_manifest::manifest::ExecutionLevel;
use embed_manifest::{embed_manifest, new_manifest};
let 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");
+436 -118
View File
@@ -1,6 +1,7 @@
//! `eframe::App` impl — three tabs (Processes, Auto-Inject Rules, Log) and a
//! global Mock Time bar at the top.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use chrono::{DateTime, Local, NaiveDate, NaiveTime, TimeZone, Utc};
@@ -9,7 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::injection_manager::InjectionManager;
use crate::process_watcher::{ProcInfo, ProcessWatcher};
use crate::rules::{CompiledRules, PatternKind, Rule};
use crate::rules::{validate_pattern, CompiledRules, PatternKind, Rule};
/// Difference between Unix epoch (1970) and FILETIME epoch (1601), in 100-ns ticks.
const UNIX_TO_FILETIME_TICKS: i64 = 116_444_736_000_000_000;
@@ -18,7 +19,62 @@ const UNIX_TO_FILETIME_TICKS: i64 = 116_444_736_000_000_000;
const MIN_YEAR: i32 = 1970;
const MAX_YEAR: i32 = 2200;
fn min_date() -> NaiveDate {
NaiveDate::from_ymd_opt(MIN_YEAR, 1, 1).expect("MIN_YEAR-01-01 is a valid date")
}
fn max_date() -> NaiveDate {
NaiveDate::from_ymd_opt(MAX_YEAR, 12, 31).expect("MAX_YEAR-12-31 is a valid date")
}
/// Clamp a whole date into `[MIN_YEAR-01-01, MAX_YEAR-12-31]`. Clamping only
/// the year field (the previous behavior) can jump the date across the
/// boundary entirely — e.g. `1970-01-01` minus a day becomes `1969-12-31`,
/// whose year-clamped form is `1970-12-31`, eleven months away from the
/// intended `1970-01-01` floor.
fn clamp_date(d: NaiveDate) -> NaiveDate {
d.clamp(min_date(), max_date())
}
/// Per-rule compile error, aligned by index with the input slice. `None`
/// means the rule's pattern compiles for its kind.
fn compile_rule_errors(rules: &[Rule]) -> Vec<Option<String>> {
rules
.iter()
.map(|r| {
validate_pattern(r.kind, &r.pattern)
.err()
.map(|e| e.to_string())
})
.collect()
}
/// Given the persisted ISO-8601 UTC instant of the last applied fake time,
/// compute the delta (in FILETIME ticks) needed to resume showing that exact
/// instant "now", and the local time to preload into the picker. Falls back
/// to `(0, Local::now())` — real time, nothing restored — when nothing was
/// stored, the stored value doesn't parse, or the FILETIME conversion would
/// overflow.
fn restore_delta_and_picker(last_fake_date: Option<&str>) -> (i64, DateTime<Local>) {
let fallback = || (0i64, Local::now());
let Some(last) = last_fake_date else {
return fallback();
};
let Ok(parsed) = DateTime::parse_from_rfc3339(last) else {
return fallback();
};
let stored_utc = parsed.with_timezone(&Utc);
let (Some(fake_ft), Some(real_ft)) = (
unix_micros_to_filetime_ticks(stored_utc.timestamp_micros()),
unix_micros_to_filetime_ticks(Utc::now().timestamp_micros()),
) else {
return fallback();
};
(fake_ft - real_ft, stored_utc.with_timezone(&Local))
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Persistent {
rules: Vec<Rule>,
auto_inject_enabled: bool,
@@ -57,14 +113,42 @@ pub struct TimeMockerApp {
picker_view_month: u32,
current_delta_ticks: i64,
status_msg: Option<String>,
/// Cache of `persistent.rules` compiled into matchers, rebuilt only when
/// `compiled_rules_snapshot` no longer equals `persistent.rules` — the
/// auto-inject scan runs every 1.5s and would otherwise recompile every
/// glob/regex in the rule set on every tick.
compiled_rules: CompiledRules,
compiled_rules_snapshot: Vec<Rule>,
/// Per-rule compile error (aligned by index with `persistent.rules`),
/// refreshed alongside `compiled_rules`. `None` means the rule compiles;
/// `Some(msg)` is shown in the Rules grid so a bad pattern (e.g. from a
/// hand-edited settings file) is visible instead of just silently never
/// matching.
rule_errors: Vec<Option<String>>,
/// Bad patterns already logged once, keyed by `"{kind}:{pattern}"`, so a
/// persistently-invalid rule doesn't re-log every time the cache above
/// is rebuilt for an unrelated change.
invalid_rules_logged: HashSet<String>,
/// PIDs the auto-inject scanner has already tried and failed to inject
/// (e.g. protected process, 32-bit target, missing hook DLL). Skipped on
/// later scans instead of being retried — and re-logged — every 1.5s
/// forever; cleared once the PID exits (see `refresh_processes_if_due`).
auto_inject_failed: HashSet<u32>,
}
impl TimeMockerApp {
pub fn new(cc: &CreationContext<'_>) -> Self {
let persistent: Persistent = cc
.storage
.and_then(|s| eframe::get_value(s, "time_mocker"))
.unwrap_or_default();
// `get_string` tells us whether a value was actually stored under
// this key; `get_value` additionally tells us whether it parsed. If
// it was stored but didn't parse (corrupted file, or a persisted
// schema the current binary can no longer read), fall back to
// defaults but say so — silently wiping every saved rule with no
// indication why is the failure mode this guards against.
let stored_raw = cc.storage.and_then(|s| s.get_string("time_mocker"));
let parsed: Option<Persistent> =
cc.storage.and_then(|s| eframe::get_value(s, "time_mocker"));
let settings_load_failed = stored_raw.is_some() && parsed.is_none();
let persistent = parsed.unwrap_or_default();
let (manager, manager_err) = match InjectionManager::new() {
Ok(m) => (Some(m), None),
@@ -75,11 +159,29 @@ impl TimeMockerApp {
watcher.refresh();
let processes = watcher.list();
let now_local = Local::now();
let date = now_local.date_naive();
let time = now_local.time();
use chrono::{Datelike, Timelike};
// Restore the last applied mock across restarts: recompute the
// delta against the stored fake instant and the current real time,
// so the fake clock resumes exactly where it was left rather than
// silently reverting to real time. A missing/unparsable/overflowing
// stored value falls back to delta 0 (real time), which is always a
// safe default.
let (current_delta_ticks, picker_dt) =
restore_delta_and_picker(persistent.last_fake_date.as_deref());
let date = picker_dt.date_naive();
let time = picker_dt.time();
let compiled_rules = CompiledRules::compile(&persistent.rules);
let rule_errors = compile_rule_errors(&persistent.rules);
let compiled_rules_snapshot = persistent.rules.clone();
Self {
status_msg: if settings_load_failed {
Some("settings could not be loaded, defaults applied".into())
} else {
None
},
persistent,
tab: Tab::Processes,
manager,
@@ -99,20 +201,55 @@ impl TimeMockerApp {
fake_second: time.second(),
picker_view_year: date.year(),
picker_view_month: date.month(),
current_delta_ticks: 0,
status_msg: None,
current_delta_ticks,
compiled_rules,
compiled_rules_snapshot,
rule_errors,
invalid_rules_logged: HashSet::new(),
auto_inject_failed: HashSet::new(),
}
}
/// Recompile `compiled_rules` (and the per-rule error list shown in the
/// Rules grid) only when `persistent.rules` actually changed since the
/// last compile — called every frame, cheap in the common no-change
/// case since it's just a `Vec<Rule>` comparison.
fn sync_compiled_rules(&mut self) {
if self.persistent.rules == self.compiled_rules_snapshot {
return;
}
self.compiled_rules = CompiledRules::compile(&self.persistent.rules);
self.rule_errors = compile_rule_errors(&self.persistent.rules);
for (rule, err) in self.persistent.rules.iter().zip(&self.rule_errors) {
if let Some(err) = err {
let key = format!("{}:{}", rule.kind.label(), rule.pattern);
if self.invalid_rules_logged.insert(key) {
if let Some(m) = self.manager.as_mut() {
m.log_push(format!(
"rule invalid ({}): `{}` — {err}",
rule.kind.label(),
rule.pattern
));
}
}
}
}
self.compiled_rules_snapshot = self.persistent.rules.clone();
}
fn refresh_processes_if_due(&mut self) {
if self.last_refresh.elapsed() >= Duration::from_millis(1500) {
self.watcher.refresh();
self.processes = self.watcher.list();
self.processes
.sort_by_key(|a| a.name.to_lowercase());
self.processes.sort_by_key(|a| a.name.to_lowercase());
let alive = self.watcher.alive_with_start_times();
if let Some(m) = self.manager.as_mut() {
m.prune_dead(&self.watcher.alive_pids());
m.prune_dead(&alive);
}
// A PID that previously failed to auto-inject and has since
// exited is free to be retried if a new process reuses it.
self.auto_inject_failed
.retain(|pid| alive.contains_key(pid));
self.last_refresh = Instant::now();
}
}
@@ -126,17 +263,28 @@ impl TimeMockerApp {
}
self.last_auto_inject_scan = Instant::now();
let compiled = CompiledRules::compile(&self.persistent.rules);
let Some(manager) = self.manager.as_mut() else {
return;
};
for proc in &self.processes {
if manager.is_injected(proc.pid) {
if manager.is_injected(proc.pid) || self.auto_inject_failed.contains(&proc.pid) {
continue;
}
if compiled.matches(&proc.path, &proc.name) {
let _ = manager.inject(proc.pid, &proc.name, &proc.path, self.current_delta_ticks);
if self.compiled_rules.matches(&proc.path, &proc.name)
&& manager
.inject(
proc.pid,
&proc.name,
&proc.path,
proc.start_time,
self.current_delta_ticks,
)
.is_err()
{
// `inject` already logs the failure; remember the PID so we
// don't retry (and re-log) it every 1.5s forever.
self.auto_inject_failed.insert(proc.pid);
}
}
}
@@ -157,8 +305,9 @@ impl TimeMockerApp {
None => {
// DST gap (spring-forward) or ambiguous (fall-back) — surface so the
// user knows the click was a no-op.
self.status_msg =
Some("DST transition: time is ambiguous or skipped — pick a nearby minute".into());
self.status_msg = Some(
"DST transition: time is ambiguous or skipped — pick a nearby minute".into(),
);
return;
}
};
@@ -180,9 +329,12 @@ impl TimeMockerApp {
self.status_msg = None;
}
/// "Now" button — set the picker to current local time and apply, which
/// drives delta ≈ 0 (i.e., disable any mock). Auto-apply matches the
/// label's verb-form ("Now" = "go to now"), not a passive reset.
/// "Now" button — set the picker to current local time and the delta to
/// exactly zero (i.e., disable any mock). Sets the delta directly rather
/// than routing through `apply_fake_time`, which derives it from two
/// independently-truncated `Utc::now()` calls (whole-second picker vs.
/// sub-second-accurate apply-time) and so leaves up to −1s of residual
/// delta instead of a clean 0.
fn reset_to_now_and_apply(&mut self) {
use chrono::{Datelike, Timelike};
let now = Local::now();
@@ -194,7 +346,12 @@ impl TimeMockerApp {
self.fake_hour = t.hour();
self.fake_minute = t.minute();
self.fake_second = t.second();
self.apply_fake_time();
self.current_delta_ticks = 0;
if let Some(m) = self.manager.as_ref() {
m.set_delta_all(0);
}
self.persistent.last_fake_date = Some(Utc::now().to_rfc3339());
self.status_msg = None;
}
fn ui_top_bar(&mut self, ui: &mut egui::Ui) {
@@ -260,12 +417,8 @@ impl TimeMockerApp {
// Month-nav header — chevrons clamp at MIN_YEAR-01 / MAX_YEAR-12 so
// the user can't browse outside the supported FILETIME range.
ui.horizontal(|ui| {
let can_prev =
self.picker_view_year > MIN_YEAR || self.picker_view_month > 1;
if ui
.add_enabled(can_prev, egui::Button::new("◀"))
.clicked()
{
let can_prev = self.picker_view_year > MIN_YEAR || self.picker_view_month > 1;
if ui.add_enabled(can_prev, egui::Button::new("◀")).clicked() {
if self.picker_view_month == 1 {
self.picker_view_year -= 1;
self.picker_view_month = 12;
@@ -273,25 +426,18 @@ impl TimeMockerApp {
self.picker_view_month -= 1;
}
}
ui.with_layout(
egui::Layout::top_down(egui::Align::Center),
|ui| {
ui.label(
egui::RichText::new(format!(
"{} {}",
month_name(self.picker_view_month),
self.picker_view_year
))
.strong(),
);
},
);
let can_next =
self.picker_view_year < MAX_YEAR || self.picker_view_month < 12;
if ui
.add_enabled(can_next, egui::Button::new("▶"))
.clicked()
{
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.label(
egui::RichText::new(format!(
"{} {}",
month_name(self.picker_view_month),
self.picker_view_year
))
.strong(),
);
});
let can_next = self.picker_view_year < MAX_YEAR || self.picker_view_month < 12;
if ui.add_enabled(can_next, egui::Button::new("▶")).clicked() {
if self.picker_view_month == 12 {
self.picker_view_year += 1;
self.picker_view_month = 1;
@@ -357,24 +503,19 @@ impl TimeMockerApp {
// the inner widgets have rendered so DragValue's own
// commit-on-Enter / cancel-on-Esc behaviors still win when focused.
let mut apply_via_button = false;
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(egui::Button::new("Apply").min_size(egui::vec2(80.0, 0.0)))
.clicked()
{
apply_via_button = true;
}
},
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Apply").min_size(egui::vec2(80.0, 0.0)))
.clicked()
{
apply_via_button = true;
}
});
let apply_via_enter = ui.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)
});
let cancel_via_esc = ui.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)
});
let apply_via_enter =
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let cancel_via_esc =
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if apply_via_button || apply_via_enter {
self.apply_fake_time();
@@ -388,15 +529,10 @@ impl TimeMockerApp {
use chrono::Datelike;
// First day of the view month (used to compute leading offset).
let first = NaiveDate::from_ymd_opt(
self.picker_view_year,
self.picker_view_month,
1,
)
.unwrap_or_else(|| {
NaiveDate::from_ymd_opt(2000, 1, 1)
.expect("2000-01-01 is a valid date")
});
let first = NaiveDate::from_ymd_opt(self.picker_view_year, self.picker_view_month, 1)
.unwrap_or_else(|| {
NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date")
});
let first_weekday = first.weekday().num_days_from_sunday() as i32; // 0=Sun..6=Sat
let today = Local::now().date_naive();
@@ -432,13 +568,11 @@ impl TimeMockerApp {
// Build button: dim out-of-month, fill selected,
// outline today (unless today is also the selected day).
let mut text =
egui::RichText::new(format!("{:>2}", cell_date.day()));
let mut text = egui::RichText::new(format!("{:>2}", cell_date.day()));
if !in_month {
text = text.color(egui::Color32::from_gray(110));
}
let mut btn =
egui::Button::new(text).min_size(egui::vec2(32.0, 32.0));
let mut btn = egui::Button::new(text).min_size(egui::vec2(32.0, 32.0));
if is_selected {
btn = btn.fill(egui::Color32::from_rgb(70, 130, 220));
}
@@ -450,17 +584,20 @@ impl TimeMockerApp {
}
if ui.add(btn).clicked() {
// Clamp year before commit so an out-of-month click
// near 1970-01 or 2200-12 can't escape range bounds.
let y = cell_date.year().clamp(MIN_YEAR, MAX_YEAR);
self.fake_year = y;
self.fake_month = cell_date.month();
self.fake_day = cell_date.day();
// Clamp the whole date before commit so an
// out-of-month click near 1970-01 or 2200-12
// can't escape range bounds — clamping only the
// year (the previous behavior) can jump the date
// across the boundary entirely.
let clamped = clamp_date(cell_date);
self.fake_year = clamped.year();
self.fake_month = clamped.month();
self.fake_day = clamped.day();
// If user clicked an out-of-month dim cell, jump
// the view to that month too.
if !in_month {
self.picker_view_year = y;
self.picker_view_month = cell_date.month();
self.picker_view_year = clamped.year();
self.picker_view_month = clamped.month();
}
}
}
@@ -469,26 +606,26 @@ impl TimeMockerApp {
});
}
/// Shift the picker date by `delta` whole days, clamping year to
/// `MIN_YEAR..=MAX_YEAR`. Time stays the same. Keeps the popup view in
/// sync with the new month.
/// Shift the picker date by `delta` whole days, clamping the result to
/// `MIN_YEAR-01-01..=MAX_YEAR-12-31`. Time stays the same. Keeps the
/// popup view in sync with the new month.
fn shift_day(&mut self, delta: i64) {
use chrono::Datelike;
let Some(naive) = self.picked_naive_dt() else {
self.status_msg = Some("invalid date/time fields".into());
return;
};
let Some(shifted) =
naive.checked_add_signed(chrono::Duration::days(delta))
else {
let Some(shifted) = naive.checked_add_signed(chrono::Duration::days(delta)) else {
return;
};
let d = shifted.date();
let y = d.year().clamp(MIN_YEAR, MAX_YEAR);
self.fake_year = y;
// Clamp the whole date, not just the year: `1970-01-01` minus a day
// is `1969-12-31`, whose year-clamped form is `1970-12-31` — eleven
// months past the intended floor of `1970-01-01`.
let d = clamp_date(shifted.date());
self.fake_year = d.year();
self.fake_month = d.month();
self.fake_day = d.day();
self.picker_view_year = y;
self.picker_view_year = d.year();
self.picker_view_month = d.month();
}
@@ -499,15 +636,17 @@ impl TimeMockerApp {
if ui.button("⟳ Refresh").clicked() {
self.watcher.refresh();
self.processes = self.watcher.list();
self.processes
.sort_by_key(|a| a.name.to_lowercase());
self.processes.sort_by_key(|a| a.name.to_lowercase());
}
});
ui.separator();
let manager_ready = self.manager.is_some();
if let Some(err) = &self.manager_err {
ui.colored_label(egui::Color32::RED, format!("InjectionManager unavailable: {err}"));
ui.colored_label(
egui::Color32::RED,
format!("InjectionManager unavailable: {err}"),
);
}
let needle = self.search.to_lowercase();
@@ -545,9 +684,13 @@ impl TimeMockerApp {
if resp.changed() {
if let Some(m) = self.manager.as_mut() {
if checked {
if let Err(e) =
m.inject(p.pid, &p.name, &p.path, self.current_delta_ticks)
{
if let Err(e) = m.inject(
p.pid,
&p.name,
&p.path,
p.start_time,
self.current_delta_ticks,
) {
self.status_msg = Some(format!("inject failed: {e}"));
}
} else {
@@ -580,13 +723,22 @@ impl TimeMockerApp {
ui.selectable_value(&mut self.rule_kind, PatternKind::Glob, "Glob");
ui.selectable_value(&mut self.rule_kind, PatternKind::Regex, "Regex");
});
if ui.button("+ Add Rule").clicked() && !self.rule_input.trim().is_empty() {
self.persistent.rules.push(Rule {
pattern: self.rule_input.trim().to_owned(),
kind: self.rule_kind,
enabled: true,
});
self.rule_input.clear();
if ui.button("+ Add Rule").clicked() {
let pattern = self.rule_input.trim().to_owned();
if pattern.is_empty() {
// No-op — nothing to validate or add.
} else if let Err(e) = validate_pattern(self.rule_kind, &pattern) {
self.status_msg =
Some(format!("invalid {} pattern: {e}", self.rule_kind.label()));
} else {
self.persistent.rules.push(Rule {
pattern,
kind: self.rule_kind,
enabled: true,
});
self.rule_input.clear();
self.status_msg = None;
}
}
});
ui.separator();
@@ -605,7 +757,18 @@ impl TimeMockerApp {
for (i, rule) in self.persistent.rules.iter_mut().enumerate() {
ui.checkbox(&mut rule.enabled, "");
ui.label(rule.kind.label());
ui.label(&rule.pattern);
// Rules that fail to compile (e.g. from a hand-edited
// settings file) are shown red with the error as a
// tooltip instead of silently never matching.
match self.rule_errors.get(i).and_then(|e| e.as_deref()) {
Some(err) => {
ui.colored_label(egui::Color32::RED, &rule.pattern)
.on_hover_text(err);
}
None => {
ui.label(&rule.pattern);
}
}
if ui.button("✕").clicked() {
remove_idx = Some(i);
}
@@ -641,6 +804,7 @@ impl eframe::App for TimeMockerApp {
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.sync_compiled_rules();
self.refresh_processes_if_due();
self.auto_inject_scan_if_due();
ctx.request_repaint_after(Duration::from_millis(500));
@@ -743,15 +907,169 @@ mod tests {
#[test]
fn unix_micros_to_filetime_ticks_overflow_on_add() {
// Create a value that, when multiplied by 10, still fits i64
// but adding UNIX_TO_FILETIME_TICKS causes overflow
// UNIX_TO_FILETIME_TICKS is ~1.16e17, i64::MAX is ~9.2e18
// So we need a very large multiplied value
let _near_max = i64::MAX / 10 - 1; // safe for mul by 10
// This should be OK since (v*10) + offset ≤ i64::MAX
let v = (i64::MAX - UNIX_TO_FILETIME_TICKS + 1) / 10;
// i64::MAX / 10 multiplied back by 10 still fits in i64 (the mul
// step succeeds), but adding UNIX_TO_FILETIME_TICKS on top pushes it
// past i64::MAX — this exercises the *add* overflow branch
// specifically, distinct from the mul-overflow case above.
let v = i64::MAX / 10;
assert!(
v.checked_mul(10).is_some(),
"mul step must not overflow here"
);
let result = unix_micros_to_filetime_ticks(v);
assert!(result.is_some());
assert!(
result.is_none(),
"add step should overflow once UNIX_TO_FILETIME_TICKS is added"
);
}
#[test]
fn clamp_date_below_min_clamps_to_floor() {
// 1970-01-01 minus a day must clamp to the floor. Clamping only the
// year would land on 1970-12-31 instead of the intended floor.
let below_min = NaiveDate::from_ymd_opt(1969, 12, 31).unwrap();
assert_eq!(clamp_date(below_min), min_date());
}
#[test]
fn clamp_date_above_max_clamps_to_ceiling() {
let above_max = NaiveDate::from_ymd_opt(2201, 1, 1).unwrap();
assert_eq!(clamp_date(above_max), max_date());
}
#[test]
fn clamp_date_within_range_is_unchanged() {
let d = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
assert_eq!(clamp_date(d), d);
}
#[test]
fn restore_delta_and_picker_none_yields_zero_delta() {
let (delta, _) = restore_delta_and_picker(None);
assert_eq!(delta, 0);
}
#[test]
fn restore_delta_and_picker_unparsable_yields_zero_delta() {
let (delta, _) = restore_delta_and_picker(Some("not-a-date"));
assert_eq!(delta, 0);
}
#[test]
fn restore_delta_and_picker_recomputes_delta_against_now() {
let stored = Utc::now() - chrono::Duration::seconds(100);
let (delta, picker) = restore_delta_and_picker(Some(&stored.to_rfc3339()));
let expected_ticks = -100 * 10_000_000i64;
// Slack for wall-clock time elapsed between building `stored` and
// the call under test.
assert!(
(delta - expected_ticks).abs() < 10_000_000,
"delta {delta} should be close to {expected_ticks}"
);
assert_eq!(
picker.date_naive(),
stored.with_timezone(&Local).date_naive()
);
}
fn test_app() -> TimeMockerApp {
TimeMockerApp {
persistent: Persistent::default(),
tab: Tab::Processes,
manager: None,
manager_err: None,
watcher: ProcessWatcher::new(),
processes: Vec::new(),
last_refresh: Instant::now(),
last_auto_inject_scan: Instant::now(),
search: String::new(),
rule_input: String::new(),
rule_kind: PatternKind::Glob,
fake_year: 2024,
fake_month: 6,
fake_day: 15,
fake_hour: 12,
fake_minute: 0,
fake_second: 0,
picker_view_year: 2024,
picker_view_month: 6,
current_delta_ticks: 0,
status_msg: None,
compiled_rules: CompiledRules::compile(&[]),
compiled_rules_snapshot: Vec::new(),
rule_errors: Vec::new(),
invalid_rules_logged: HashSet::new(),
auto_inject_failed: HashSet::new(),
}
}
#[test]
fn shift_day_clamps_at_min_boundary() {
let mut app = test_app();
app.fake_year = MIN_YEAR;
app.fake_month = 1;
app.fake_day = 1;
app.shift_day(-1);
assert_eq!(
(app.fake_year, app.fake_month, app.fake_day),
(MIN_YEAR, 1, 1)
);
}
#[test]
fn shift_day_clamps_at_max_boundary() {
let mut app = test_app();
app.fake_year = MAX_YEAR;
app.fake_month = 12;
app.fake_day = 31;
app.shift_day(1);
assert_eq!(
(app.fake_year, app.fake_month, app.fake_day),
(MAX_YEAR, 12, 31)
);
}
#[test]
fn shift_day_normal_advance_is_not_clamped() {
let mut app = test_app();
app.fake_year = 2024;
app.fake_month = 6;
app.fake_day = 15;
app.shift_day(1);
assert_eq!((app.fake_year, app.fake_month, app.fake_day), (2024, 6, 16));
}
#[test]
fn reset_to_now_and_apply_zeroes_delta_exactly() {
let mut app = test_app();
app.current_delta_ticks = 123_456_789;
app.reset_to_now_and_apply();
assert_eq!(
app.current_delta_ticks, 0,
"Now must leave exactly zero delta, not a residual sub-second offset"
);
}
#[test]
fn persistent_defaults_missing_fields_instead_of_failing() {
// Simulates a settings file written by an older/newer binary that's
// missing a field the current struct expects — deserializing an
// empty object should succeed via #[serde(default)] rather than
// erroring and wiping every other (present) field.
let restored: Persistent = ron::from_str("()").expect("empty RON should deserialize");
assert!(restored.rules.is_empty());
assert!(!restored.auto_inject_enabled);
assert!(restored.last_fake_date.is_none());
}
#[test]
fn rule_missing_field_defaults_instead_of_failing() {
// A `Rule` written before some future field addition should still
// deserialize via #[serde(default)] instead of invalidating the
// whole `rules` vector it lives in.
let restored: Rule = ron::from_str(r#"(pattern:"*.exe",kind:Glob)"#)
.expect("partial Rule should deserialize");
assert_eq!(restored.pattern, "*.exe");
assert!(restored.enabled, "missing `enabled` should default to true");
}
}
+174 -22
View File
@@ -8,6 +8,7 @@
//! 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};
@@ -52,6 +53,11 @@ 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,
}
@@ -124,13 +130,24 @@ impl InjectionManager {
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(),
(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}"))),
@@ -142,8 +159,15 @@ impl InjectionManager {
self.injected.values()
}
pub fn inject(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
match self.inject_inner(pid, name, path, initial_delta) {
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
@@ -155,7 +179,14 @@ impl InjectionManager {
}
}
fn inject_inner(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
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(());
}
@@ -166,11 +197,20 @@ impl InjectionManager {
));
}
// 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(pid)
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!(
@@ -187,7 +227,7 @@ impl InjectionManager {
));
}
if !is_native_x64(pid) {
if !is_native_x64(handle) {
return Err(anyhow!(
"pid={pid} is not a native x64 process; the AMD64 hook DLL cannot be injected"
));
@@ -201,8 +241,25 @@ impl InjectionManager {
}
delta.write_delta(initial_delta);
let process = OwnedProcess::from_pid(pid)
.with_context(|| format!("open process pid={pid}"))?;
// 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)
@@ -215,6 +272,7 @@ impl InjectionManager {
pid,
name: name.to_owned(),
path: path.to_owned(),
start_time,
delta,
_syringe: syringe,
},
@@ -238,20 +296,38 @@ impl InjectionManager {
}
}
/// Drop entries for processes that have exited.
pub fn prune_dead(&mut self, alive: &std::collections::HashSet<u32>) {
let dead: Vec<u32> = self
.injected
.keys()
.copied()
.filter(|pid| !alive.contains(pid))
.collect();
for pid in dead {
/// 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
));
}
}
}
fn log_push(&mut self, line: String) {
pub(crate) fn log_push(&mut self, line: String) {
self.log.push_back(line);
while self.log.len() > LOG_CAP {
self.log.pop_front();
@@ -394,14 +470,90 @@ mod tests {
assert!(!paths_equivalent("C:\\foo.exe", ""));
}
#[test]
fn injection_manager_log_bounded() {
let mut manager = InjectionManager {
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) {
+32 -2
View File
@@ -26,10 +26,40 @@ fn main() -> Result<()> {
..Default::default()
};
eframe::run_native(
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}"))
.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,
);
}
}
+17 -4
View File
@@ -3,7 +3,7 @@
//! Pure data — no UI, no injection. The `App` polls `refresh()` and decides
//! what to inject based on `CompiledRules`.
use std::collections::HashSet;
use std::collections::HashMap;
use sysinfo::System;
@@ -12,6 +12,10 @@ 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 {
@@ -24,7 +28,8 @@ impl ProcessWatcher {
}
pub fn refresh(&mut self) {
self.sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
self.sys
.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
}
pub fn list(&self) -> Vec<ProcInfo> {
@@ -44,12 +49,20 @@ impl ProcessWatcher {
pid: pid.as_u32(),
name,
path,
start_time: proc.start_time(),
})
})
.collect()
}
pub fn alive_pids(&self) -> HashSet<u32> {
self.sys.processes().keys().map(|p| p.as_u32()).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()
}
}
+123 -6
View File
@@ -1,8 +1,8 @@
//! Auto-inject pattern rules — glob or regex matched against process path and name.
use anyhow::{anyhow, Result};
use globset::{Glob, GlobMatcher};
use regex::Regex;
use globset::{GlobBuilder, GlobMatcher};
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -20,13 +20,24 @@ impl PatternKind {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[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>,
@@ -57,20 +68,45 @@ impl CompiledRules {
}
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| m.is_match(path) || m.is_match(name))
.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 => {
let g = Glob::new(&rule.pattern).map_err(|e| anyhow!("glob: {e}"))?;
// 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(
Regex::new(&rule.pattern).map_err(|e| anyhow!("regex: {e}"))?,
RegexBuilder::new(&rule.pattern)
.case_insensitive(true)
.build()
.map_err(|e| anyhow!("regex: {e}"))?,
)),
}
}
@@ -113,4 +149,85 @@ mod tests {
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"));
}
}
+61 -28
View File
@@ -9,10 +9,8 @@ use std::io::{self, Read};
use std::os::windows::ffi::OsStringExt;
use std::path::Path;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
IsWow64Process2, OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
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;
@@ -38,24 +36,28 @@ pub fn pe_machine(path: &Path) -> io::Result<u16> {
}
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"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"missing PE signature",
));
}
Ok(u16::from_le_bytes([buf[e_lfanew + 4], buf[e_lfanew + 5]]))
}
/// Return the full image path of a live process, or `Err` if the process
/// is gone / inaccessible. Used to detect PID reuse between watcher
/// refresh and inject.
pub fn query_full_image_name(pid: u32) -> io::Result<String> {
/// 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 h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return Err(io::Error::last_os_error());
}
let mut buf = [0u16; 1024];
let mut size = buf.len() as u32;
let ok = QueryFullProcessImageNameW(h, 0, buf.as_mut_ptr(), &mut size);
CloseHandle(h);
let ok = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
if ok == 0 {
return Err(io::Error::last_os_error());
}
@@ -65,19 +67,14 @@ pub fn query_full_image_name(pid: u32) -> io::Result<String> {
}
}
/// True iff the target process is native AMD64 (not WOW64). The hook DLL is
/// AMD64-only; injecting it into a 32-bit WOW64 process produces an opaque
/// dll-syringe error well after the user committed.
pub fn is_native_x64(pid: u32) -> bool {
/// 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 h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return false;
}
let mut process_machine: u16 = 0;
let mut native_machine: u16 = 0;
let ok = IsWow64Process2(h, &mut process_machine, &mut native_machine);
CloseHandle(h);
let ok = IsWow64Process2(handle, &mut process_machine, &mut native_machine);
if ok == 0 {
return false;
}
@@ -90,6 +87,8 @@ pub fn is_native_x64(pid: u32) -> bool {
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() {
@@ -101,8 +100,20 @@ mod tests {
.map(|p| p.join("target/release/time_mocker_hook.dll"))
.expect("derive target/release path");
// Skip test if DLL not found (e.g., release build not run yet)
// `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()
@@ -177,14 +188,36 @@ mod tests {
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 self_pid = std::process::id();
let result = is_native_x64(self_pid);
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"
);
}
}