From ffab962360c9f2ed31cd1d0a75456e95f3364f02 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:13:27 +0700 Subject: [PATCH] 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. --- Cargo.lock | 1 + crates/time-mocker-test-target/src/main.rs | 15 +- crates/time-mocker-ui/Cargo.toml | 5 + crates/time-mocker-ui/build.rs | 20 +- crates/time-mocker-ui/src/app.rs | 554 ++++++++++++++---- .../time-mocker-ui/src/injection_manager.rs | 196 ++++++- crates/time-mocker-ui/src/main.rs | 34 +- crates/time-mocker-ui/src/process_watcher.rs | 21 +- crates/time-mocker-ui/src/rules.rs | 129 +++- .../time-mocker-ui/src/win32_process_info.rs | 89 ++- 10 files changed, 870 insertions(+), 194 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bafa5a..3929d2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2466,6 +2466,7 @@ dependencies = [ "embed-manifest", "globset", "regex", + "ron", "serde", "sysinfo 0.32.1", "tempfile", diff --git a/crates/time-mocker-test-target/src/main.rs b/crates/time-mocker-test-target/src/main.rs index ce18f83..31b1d82 100644 --- a/crates/time-mocker-test-target/src/main.rs +++ b/crates/time-mocker-test-target/src/main.rs @@ -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::("kernel32.dll", "GetSystemTimeAsFileTime") - }; + let get_system_time_as_file_time = + unsafe { resolve::("kernel32.dll", "GetSystemTimeAsFileTime") }; let get_system_time_precise_as_file_time = unsafe { resolve::("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 { diff --git a/crates/time-mocker-ui/Cargo.toml b/crates/time-mocker-ui/Cargo.toml index e531467..649b9d4 100644 --- a/crates/time-mocker-ui/Cargo.toml +++ b/crates/time-mocker-ui/Cargo.toml @@ -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" diff --git a/crates/time-mocker-ui/build.rs b/crates/time-mocker-ui/build.rs index fd4f7b3..8ba9e6e 100644 --- a/crates/time-mocker-ui/build.rs +++ b/crates/time-mocker-ui/build.rs @@ -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"); diff --git a/crates/time-mocker-ui/src/app.rs b/crates/time-mocker-ui/src/app.rs index 580576d..767f8d8 100644 --- a/crates/time-mocker-ui/src/app.rs +++ b/crates/time-mocker-ui/src/app.rs @@ -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> { + 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) { + 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, auto_inject_enabled: bool, @@ -57,14 +113,42 @@ pub struct TimeMockerApp { picker_view_month: u32, current_delta_ticks: i64, status_msg: Option, + /// 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, + /// 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>, + /// 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, + /// 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, } 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 = + 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` 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"); + } } diff --git a/crates/time-mocker-ui/src/injection_manager.rs b/crates/time-mocker-ui/src/injection_manager.rs index 44bc382..e1d867e 100644 --- a/crates/time-mocker-ui/src/injection_manager.rs +++ b/crates/time-mocker-ui/src/injection_manager.rs @@ -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) { - let dead: Vec = 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) { + 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) { diff --git a/crates/time-mocker-ui/src/main.rs b/crates/time-mocker-ui/src/main.rs index 6a6c16e..ea5941c 100644 --- a/crates/time-mocker-ui/src/main.rs +++ b/crates/time-mocker-ui/src/main.rs @@ -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 { 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, + ); + } } diff --git a/crates/time-mocker-ui/src/process_watcher.rs b/crates/time-mocker-ui/src/process_watcher.rs index 3d33791..3776267 100644 --- a/crates/time-mocker-ui/src/process_watcher.rs +++ b/crates/time-mocker-ui/src/process_watcher.rs @@ -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 { @@ -44,12 +49,20 @@ impl ProcessWatcher { pid: pid.as_u32(), name, path, + start_time: proc.start_time(), }) }) .collect() } - pub fn alive_pids(&self) -> HashSet { - 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 { + self.sys + .processes() + .iter() + .map(|(pid, proc)| (pid.as_u32(), proc.start_time())) + .collect() } } diff --git a/crates/time-mocker-ui/src/rules.rs b/crates/time-mocker-ui/src/rules.rs index 7a9bf50..f4618dd 100644 --- a/crates/time-mocker-ui/src/rules.rs +++ b/crates/time-mocker-ui/src/rules.rs @@ -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, @@ -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 { 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")); + } } diff --git a/crates/time-mocker-ui/src/win32_process_info.rs b/crates/time-mocker-ui/src/win32_process_info.rs index c3b9a60..4ab5884 100644 --- a/crates/time-mocker-ui/src/win32_process_info.rs +++ b/crates/time-mocker-ui/src/win32_process_info.rs @@ -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 { } 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 { +/// 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 { 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 { } } -/// 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" + ); + } }