mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-10-11 03:13:47 +00:00
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:
1 parent
f4bd35ae5b
commit
ffab962360
10 files changed
+870
-194
No files matched your search
Generated
+1
@@ -2466,6 +2466,7 @@ dependencies = [
|
||||
"embed-manifest",
|
||||
"globset",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"sysinfo 0.32.1",
|
||||
"tempfile",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user