crates/veilvoice-watch/src/windows.rs
what this file is for · veilvoice-watch · 606 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Windows detection, via the Capability Access Manager.
//!
//! # Where the answer lives
//!
//! Windows records every application's use of the microphone and camera under
//! `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\
//! ConsentStore\{microphone,webcam}`. Each application gets a subkey holding
//! `LastUsedTimeStart` and `LastUsedTimeStop` as FILETIME values.
//!
//! The rule that makes this a *live* view rather than a history: an application
//! is using the device right now when its start time is non-zero and its stop
//! time is zero. Windows clears the stop value on acquisition and writes it on
//! release. This is the same bookkeeping that drives the taskbar privacy
//! indicator, so what this reports is exactly what the OS itself believes.
//!
//! Desktop programs live under a `NonPackaged` subkey, with their path encoded
//! using `#` in place of each separator. Store apps appear directly, keyed by
//! package family name.
//!
//! # One subprocess per capability, and why that had to be fixed
//!
//! This originally asked `reg.exe` for the subkeys of the store, then spawned
//! `reg.exe` **twice more for every application it found** to read the two
//! timestamps. The count is `2 + 2n` per capability, where `n` is how many
//! applications have ever asked for that device.
//!
//! Measured on the machine this was found on: 7 packaged and 19 desktop
//! applications for the microphone, 6 packaged for the camera, which is **68 process
//! creations per scan**. One `reg.exe` spawn there costs 6.6 ms at its
//! fastest, so a scan cost **at least 449 ms**, and that is the warm-cache best
//! case rather than the typical one. The desktop application called `scan` on
//! the user-interface thread every two seconds.
//!
//! The result was a window that froze repeatedly, which is what "runs extremely
//! slow and freezes every couple of seconds" meant in the report. Nothing was
//! leaking and nothing was deadlocked: it was doing a great deal of work in the
//! worst possible place.
//!
//! `reg query <key> /s` prints the **whole subtree**, keys and values together,
//! in one go. So the scan is now two spawns, one per capability, and
//! [`parse_consent_dump`] does the rest in memory. Measured on the same
//! machine: 45 ms for the whole scan, against 449 ms, and it no longer grows
//! with the number of applications installed. The parser is a pure function
//! over text, so it is tested against a captured dump on every platform rather
//! than only on the one that can produce it.
//!
//! The front end was fixed too, and separately: a scan that is fast is still
//! not something to do on the thread that paints.
//!
//! # What it cannot give you
//!
//! A PID. Windows tracks this per *application*, not per process, so
//! [`DeviceUse::pid`] is `None` here. The trade is worth it: this sees packaged
//! apps, background services and anything else the OS accounts for, which
//! enumerating process handles would miss.
//!
//! # In plain words
//!
//! Finds out which applications are using the microphone or camera on Windows.
//!
//! Windows keeps that in its own records of what has been granted access and when,
//! and this reads them. It reports per application rather than per running
//! program, because that is how Windows stores it, and the difference is stated
//! rather than papered over.
use crate::{DeviceKind, DeviceUse, Error};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Spawn without a console window.
///
/// On Windows a `Command` for a console program creates a console, and when the
/// parent is a GUI process with none of its own, Windows opens a **window** for
/// it -- appearing and vanishing as the child runs. That is what "a cmd prompt
/// flashing randomly" was: once at startup, and again on every poll.
///
/// `CREATE_NO_WINDOW` suppresses it. `creation_flags` is a **safe** API, so
/// this costs nothing against `#![forbid(unsafe_code)]`.
///
/// Every `Command::new` in this crate goes through here, and a test asserts it
/// -- because "no console window appeared" is not observable from a test, which
/// is exactly why the defect shipped.
// `mut` is only needed where the body below is compiled in. Everywhere
// else the parameter is moved straight through, and `-D warnings` in CI
// rejects the unused `mut` -- which only the Linux runner can see.
#[cfg_attr(not(windows), allow(unused_mut))]
fn no_window(mut command: Command) -> Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
/// The full hive name, not the `HKCU` abbreviation: `reg query` echoes subkey
/// paths back in long form, and the reply has to be matched against what was
/// asked for. Querying with the short form silently matched nothing.
const CONSENT_STORE: &str = r"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore";
/// FILETIME counts 100-nanosecond intervals from 1601-01-01; Unix time starts
/// at 1970-01-01. This is the gap, in seconds.
const FILETIME_TO_UNIX_SECS: u64 = 11_644_473_600;
/// The absolute path of `reg.exe`, or `None` if it is not where it should be.
///
/// **Never `Command::new("reg")`.** Rust's `Command` resolves a bare program
/// name through the platform search order, and on Windows that order includes
/// the **current working directory** ahead of most of `PATH`. Running
/// `veilvoice watch` from a directory containing a file named `reg.exe` would
/// have executed it, as the user, with no prompt. A downloads folder is
/// enough. Naming the system directory removes the search.
///
/// Returning `None` rather than falling back to a search is deliberate: this
/// module's failure mode is already "report nothing", and the crate says
/// plainly that an empty list from a blind monitor is not good news. Running an
/// unknown `reg.exe` would be a far worse answer than no answer.
fn reg_exe() -> Option<std::path::PathBuf> {
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
for directory in [
format!(r"{root}\System32"),
// A 32-bit process on 64-bit Windows is redirected away from the real
// System32; `Sysnative` is the way back to it.
format!(r"{root}\Sysnative"),
] {
let candidate = std::path::Path::new(&directory).join("reg.exe");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn scan() -> Result<Vec<DeviceUse>, Error> {
let mut found = Vec::new();
// "webcam" is what the registry calls it, even though the UI says camera.
for (kind, key) in [
(DeviceKind::Microphone, "microphone"),
(DeviceKind::Camera, "webcam"),
] {
collect(kind, &format!(r"{CONSENT_STORE}\{key}"), &mut found);
}
Ok(found)
}
/// Walk one capability's whole subtree, from a single `reg query /s`.
fn collect(kind: DeviceKind, root: &str, out: &mut Vec<DeviceUse>) {
let Some(dump) = query_tree(root) else {
return;
};
for (key, start, stop) in parse_consent_dump(&dump) {
// In use == started and not yet stopped. Anything else is history.
if start == 0 || stop != 0 {
continue;
}
let raw = key.rsplit('\\').next().unwrap_or(&key);
let path = decode_path(raw);
let app = friendly_name(&path);
out.push(DeviceUse {
kind,
app,
path: Some(path),
// Per-application accounting, not per-process. See the module note.
pid: None,
since: filetime_to_system(start),
device: None,
});
}
}
/// One `reg query <key> /s`, printing the whole subtree.
///
/// The single subprocess this module's scan costs per capability. Everything
/// after it is text.
fn query_tree(key: &str) -> Option<String> {
let reg = reg_exe()?;
let output = no_window(Command::new(reg))
.args(["query", key, "/s"])
.output()
.ok()?;
if !output.status.success() {
// A store that does not exist is the ordinary state on a machine where
// nothing has ever asked for a microphone, and is not an error.
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
/// Pull `(key, LastUsedTimeStart, LastUsedTimeStop)` out of a `/s` dump.
///
/// The shape `reg query /s` prints is a key path on its own unindented line,
/// then that key's values indented beneath it, then a blank line:
///
/// ```text
/// HKEY_CURRENT_USER\...\microphone\SomeApp
/// LastUsedTimeStart REG_QWORD 0x1db5f3a1c2d4e5f
/// LastUsedTimeStop REG_QWORD 0x0
/// ```
///
/// Only keys that carry a `LastUsedTimeStart` come back, which is exactly the
/// set of application entries. Packaged ones sit directly under the store and
/// desktop ones under `NonPackaged`, and this needs to know nothing about that
/// distinction to find both.
///
/// A key with a start time and no stop time is reported with a stop of zero:
/// Windows clears the stop value on acquisition, so *absent* and *zero* mean
/// the same thing here, and treating a missing line as "still running" is the
/// reading that errs toward telling the user something is listening.
pub(crate) fn parse_consent_dump(text: &str) -> Vec<(String, u64, u64)> {
let mut found: Vec<(String, u64, u64)> = Vec::new();
let mut key: Option<String> = None;
let mut start: Option<u64> = None;
let mut stop: Option<u64> = None;
// Close off whichever key was being read.
fn flush(
found: &mut Vec<(String, u64, u64)>,
key: &mut Option<String>,
start: &mut Option<u64>,
stop: &mut Option<u64>,
) {
if let (Some(name), Some(began)) = (key.take(), start.take()) {
found.push((name, began, stop.take().unwrap_or(0)));
}
*start = None;
*stop = None;
}
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// A key header is unindented; a value line is indented. `reg` echoes
// paths under the full hive name, which is why the store is asked for
// that way -- asking with `HKCU` matched nothing and the monitor
// reported an empty machine whatever was recording.
if !line.starts_with(' ') && !line.starts_with('\t') {
flush(&mut found, &mut key, &mut start, &mut stop);
if trimmed.starts_with("HKEY_") {
key = Some(trimmed.trim_end_matches('\\').to_string());
}
continue;
}
if key.is_none() {
continue;
}
if let Some(value) = hex_value(trimmed, "LastUsedTimeStart") {
start = Some(value);
} else if let Some(value) = hex_value(trimmed, "LastUsedTimeStop") {
stop = Some(value);
}
}
flush(&mut found, &mut key, &mut start, &mut stop);
found
}
/// `Name REG_QWORD 0x...` for one named value, as a number.
///
/// Matched by name and then by taking the last field, rather than by splitting
/// into three: the value's *name* is fixed here, so the only thing that can
/// vary is how much whitespace `reg` used, and the last field is the datum
/// whatever it did.
fn hex_value(line: &str, name: &str) -> Option<u64> {
let rest = line.strip_prefix(name)?;
// Guard against a value called `LastUsedTimeStartSomethingElse`.
if !rest.starts_with(char::is_whitespace) {
return None;
}
let raw = rest.split_whitespace().next_back()?;
u64::from_str_radix(raw.trim_start_matches("0x"), 16).ok()
}
/// Registry keys encode a path with `#` where a separator belongs.
fn decode_path(raw: &str) -> String {
raw.replace('#', "\\")
}
/// The executable name, or the package family name for a Store app.
fn friendly_name(path: &str) -> String {
let tail = path.rsplit('\\').next().unwrap_or(path);
let name = tail.strip_suffix(".exe").unwrap_or(tail);
if name.is_empty() {
path.to_string()
} else {
name.to_string()
}
}
fn filetime_to_system(filetime: u64) -> Option<SystemTime> {
let secs = filetime.checked_div(10_000_000)?;
let unix = secs.checked_sub(FILETIME_TO_UNIX_SECS)?;
UNIX_EPOCH.checked_add(Duration::from_secs(unix))
}
#[cfg(test)]
mod tests {
/// Every subprocess in this file must be spawned through `no_window`.
///
/// This reads the file's own source rather than exercising the behaviour,
/// because "no console window appeared" cannot be observed from a test --
/// which is precisely why the defect reached a release. A `Command::new`
/// added later without the wrapper fails here rather than on a desktop.
#[test]
fn every_subprocess_is_spawned_without_a_console_window() {
// A source-reading test, so the line endings have to be settled first.
// F-72: these searched for "\n}\n" and passed on every machine
// whose checkout uses LF. GitHub's Windows runners default to
// core.autocrlf=true, so the file arrives with CRLF, the pattern
// matches nothing, and three tests failed there and nowhere else --
// including on the developer machine that had just run them.
// Normalised here as well as pinned in .gitattributes: a test that
// depends on a git setting is a test somebody will trip over.
let source = include_str!("windows.rs").replace("\r\n", "\n");
let mut bare = Vec::new();
for (number, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('*') {
continue; // prose: this rule is discussed in the comments
}
if !line.contains("Command::new") || line.contains("no_window(") {
continue;
}
bare.push(format!("line {}: {}", number + 1, trimmed));
}
assert!(
bare.is_empty(),
"these spawns bypass `no_window`, so each flashes a console window \
on Windows:\n{}",
bare.join("\n")
);
}
use super::*;
#[test]
fn registry_paths_are_decoded() {
assert_eq!(
decode_path("C:#Program Files#Zoom#bin#Zoom.exe"),
r"C:\Program Files\Zoom\bin\Zoom.exe"
);
}
#[test]
fn names_are_readable() {
assert_eq!(friendly_name(r"C:\Program Files\Zoom\bin\Zoom.exe"), "Zoom");
assert_eq!(
friendly_name("Microsoft.WindowsCamera_8wekyb3d8bbwe"),
"Microsoft.WindowsCamera_8wekyb3d8bbwe"
);
}
#[test]
fn filetime_converts_to_a_sane_instant() {
// 2020-01-01T00:00:00Z as a FILETIME.
let ft = (1_577_836_800u64 + FILETIME_TO_UNIX_SECS) * 10_000_000;
let when = filetime_to_system(ft).expect("should convert");
let unix = when.duration_since(UNIX_EPOCH).unwrap().as_secs();
assert_eq!(unix, 1_577_836_800);
}
#[test]
fn a_nonsense_filetime_does_not_panic() {
assert!(filetime_to_system(0).is_none());
assert!(filetime_to_system(1).is_none());
assert!(filetime_to_system(u64::MAX).is_none());
}
/// Regression: the registry tool must be an absolute path in the system
/// directory, never a bare name the OS searches for, because the Windows search
/// order includes the current working directory, so a planted `reg.exe`
/// would have been run as the user.
#[test]
fn the_registry_tool_is_resolved_absolutely_not_searched_for() {
let path = reg_exe().expect("reg.exe should exist on Windows");
assert!(path.is_absolute(), "{} is not absolute", path.display());
assert!(path.is_file());
let shown = path.to_string_lossy().to_lowercase();
assert!(
shown.contains("system32") || shown.contains("sysnative"),
"resolved outside the system directory: {shown}"
);
}
/// And a decoy named `reg.exe` in the working directory must never become
/// the tool we run. Written without a temp-directory crate so this crate
/// stays dependency-free, dev-dependencies included.
#[test]
fn a_decoy_in_the_working_directory_is_not_picked_up() {
let scratch = std::env::temp_dir().join("veilvoice-watch-decoy-test");
std::fs::create_dir_all(&scratch).unwrap();
let decoy = scratch.join("reg.exe");
std::fs::write(&decoy, b"not really reg").unwrap();
let resolved = reg_exe().expect("reg.exe should exist on Windows");
assert_ne!(resolved, decoy, "a planted reg.exe was selected");
assert!(
!resolved.starts_with(&scratch),
"resolution reached a writable scratch directory: {}",
resolved.display()
);
let _ = std::fs::remove_file(&decoy);
}
/// Real `reg query <store> /s` output, kept verbatim. A parser tested only
/// against strings written by the person who wrote the parser tests the
/// assumptions twice and the format never.
const DUMP: &str = "\
HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone
Value REG_SZ Allow
HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\Microsoft.WindowsCamera_8wekyb3d8bbwe
Value REG_SZ Allow
LastUsedTimeStart REG_QWORD 0x1db5f3a1c2d4e5f
LastUsedTimeStop REG_QWORD 0x1db5f3a1c2d4f00
HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged
HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged\\C:#Program Files#Zoom#bin#Zoom.exe
Value REG_SZ Allow
LastUsedTimeStart REG_QWORD 0x1db6000000000000
LastUsedTimeStop REG_QWORD 0x0
HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged\\C:#Windows#System32#WindowsPowerShell#v1.0#powershell.exe
LastUsedTimeStart REG_QWORD 0x1db4000000000000
";
/// The whole subtree comes out of one dump, packaged and desktop alike,
/// without the parser needing to know which is which.
#[test]
fn one_dump_yields_every_application_entry() {
let found = parse_consent_dump(DUMP);
assert_eq!(found.len(), 3, "{found:#?}");
assert!(found.iter().any(|(key, _, _)| key.ends_with("Zoom.exe")));
assert!(found
.iter()
.any(|(key, _, _)| key.ends_with("Microsoft.WindowsCamera_8wekyb3d8bbwe")));
}
/// The store key itself and the `NonPackaged` container carry no
/// timestamps, so they must not become entries.
#[test]
fn container_keys_are_not_applications() {
for (key, _, _) in parse_consent_dump(DUMP) {
assert!(!key.ends_with("NonPackaged"), "{key}");
assert!(!key.ends_with("microphone"), "{key}");
}
}
/// The rule the whole feature turns on: started and not yet stopped.
#[test]
fn a_started_and_unstopped_entry_is_the_one_in_use() {
let found = parse_consent_dump(DUMP);
let zoom = found
.iter()
.find(|(key, _, _)| key.ends_with("Zoom.exe"))
.unwrap();
assert_eq!(zoom.2, 0, "Zoom is still holding the microphone");
let camera = found
.iter()
.find(|(key, _, _)| key.contains("WindowsCamera"))
.unwrap();
assert_ne!(camera.2, 0, "the camera app released it");
}
/// Windows clears the stop value on acquisition, so a missing stop line
/// and a zero mean the same thing -- and the reading that errs toward
/// telling somebody a microphone is live is the right one.
#[test]
fn a_missing_stop_time_reads_as_still_running() {
let found = parse_consent_dump(DUMP);
let shell = found
.iter()
.find(|(key, _, _)| key.ends_with("powershell.exe"))
.expect("an entry with no stop line must still be found");
assert_eq!(shell.2, 0);
}
/// A value whose name merely starts with the one being looked for must not
/// be mistaken for it.
#[test]
fn a_similarly_named_value_is_not_confused_for_the_real_one() {
let dump = "HKEY_CURRENT_USER\\x\\App\n LastUsedTimeStartedElsewhere REG_QWORD 0x99\n";
assert!(parse_consent_dump(dump).is_empty());
}
#[test]
fn nothing_and_rubbish_produce_nothing_rather_than_panicking() {
assert!(parse_consent_dump("").is_empty());
assert!(parse_consent_dump("\n\n \n").is_empty());
assert!(parse_consent_dump("not a registry dump at all").is_empty());
// A value line with no key above it must not be attributed to anything.
assert!(parse_consent_dump(" LastUsedTimeStart REG_QWORD 0x1").is_empty());
}
/// A malformed number must drop that value rather than the whole entry
/// silently becoming something else.
#[test]
fn an_unparseable_timestamp_is_dropped() {
let dump = "HKEY_CURRENT_USER\\x\\App\n LastUsedTimeStart REG_QWORD notanumber\n";
assert!(parse_consent_dump(dump).is_empty());
}
/// Regression, and the important one. `reg query` echoes subkey paths back
/// under the **full** hive name, so asking with the `HKCU` abbreviation
/// matched nothing and the monitor reported an empty machine no matter what
/// was recording, which is the worst possible failure for this feature, because it
/// looks like good news.
///
/// Asked against a key every Windows installation has, rather than against
/// the consent store. The consent store is populated by *applications
/// having asked for the microphone*, and a headless CI runner with no audio
/// hardware has legitimately never had one ask, so an empty result there
/// means "nothing has used the microphone on this machine", which is not the
/// same claim at all. Conflating the two made CI fail on a machine where the
/// code was working perfectly, which is its own kind of silently wrong.
#[test]
fn the_registry_tool_answers_for_a_key_that_always_exists() {
// Deliberately shallow: `/s` on HKLM\SOFTWARE\Microsoft would walk an
// enormous tree, and what is being checked is that the tool runs and
// echoes paths under the full hive name -- not how big that tree is.
let dump = query_tree(r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT")
.expect("reg.exe should answer for a key every Windows install has");
assert!(
dump.contains("HKEY_LOCAL_MACHINE\\"),
"`reg query` parsing is broken, and the monitor would report an empty \
machine whatever was recording"
);
}
/// The consent store itself, when this machine has one.
///
/// Asserts the *shape* of what comes back, never which applications are in
/// it. Both of those are facts about the machine rather than about the
/// code, and both have now failed CI for that reason: first the store was
/// empty on a runner where nothing had ever asked for a microphone, and
/// then, after that was allowed for, a runner had entries but no
/// `NonPackaged` subkey, which only appears once a *desktop* application
/// has asked. A test that fails depending on what software a machine has
/// happened to run is not testing this crate.
///
/// What is worth asserting is that every entry is a real subkey path under
/// the full hive name, because that is what the parser is for. Whether the
/// parser works at all is covered without any of this ambiguity by
/// `the_registry_parser_reads_a_key_that_always_exists`.
#[test]
fn the_consent_store_is_well_formed_when_this_machine_has_one() {
let key = format!(r"{CONSENT_STORE}\microphone");
let Some(dump) = query_tree(&key) else {
eprintln!(
"no microphone consent store on this machine - nothing has ever requested \
the microphone here, which is a fact about the machine. The parser is \
covered by the tests over the captured dump above."
);
return;
};
for (entry, _, _) in parse_consent_dump(&dump) {
assert!(
entry.starts_with(&key),
"an entry is not under the store it came from: {entry}"
);
assert!(
!entry.contains("REG_"),
"a value line leaked through: {entry}"
);
assert!(!entry.ends_with('\\'), "trailing separator on {entry}");
}
}
/// The whole reason this was rewritten: one spawn per capability, not one
/// per application. A scan must not take long enough to be noticed.
///
/// Measured as a minimum over several runs, because a single sample on a
/// machine that happened to be busy says nothing.
#[test]
fn a_scan_is_fast_enough_to_run_on_a_timer() {
let mut best = std::time::Duration::from_secs(3600);
for _ in 0..5 {
let started = std::time::Instant::now();
let _ = scan();
best = best.min(started.elapsed());
}
assert!(
best < std::time::Duration::from_millis(500),
"a scan takes {best:?}, which is what made the window freeze"
);
eprintln!("fastest scan of this machine: {best:?}");
}
/// Reading the real consent store must work on any Windows machine, and
/// must not report an application that has already released the device.
#[test]
fn scanning_the_real_consent_store_is_well_formed() {
let entries = scan().expect("scan should not fail on Windows");
for entry in &entries {
assert!(!entry.app.is_empty());
assert!(entry.path.is_some());
assert!(
matches!(entry.kind, DeviceKind::Microphone | DeviceKind::Camera),
"unexpected device kind"
);
}
}
}