crates/veilvoice-watch/src/proc.rs
what this file is for · veilvoice-watch · 274 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
//! Which processes are running, per platform, and what that cannot tell you.
//!
//! # Why this is a crate rather than a module
//!
//! Two of this workspace's security features need the same answer: which
//! programs are running. `capture` asks it about screen recorders,
//! `input` asks it about keyboard and mouse monitors, and the answer
//! is one platform-specific listing with one set of limits.
//!
//! It began inside `capture` as a private module. Leaving it there
//! and depending on that crate would mean a keyboard-monitoring feature pulling
//! in a table of screen recorders it will never look at, which is exactly what
//! the design note in `ROADMAP.md` says these crates must not do: *each is a
//! crate of its own, so that another project can depend on one without taking
//! all of them*. The alternative -- a second copy of the parser -- is worse:
//! this project already pulled the checking out of the verifier's own code so the
//! desktop application and the command line could not drift apart, and the
//! reasoning is the same here.
//!
//! # Linux reads files; the other two ask a tool
//!
//! On Linux every process publishes its own name at `/proc/<pid>/comm`, so the
//! list is a directory walk and nothing is spawned. Windows and macOS have no
//! such file, and their native APIs are FFI -- `#![forbid(unsafe_code)]` holds
//! here as it does everywhere else in the workspace, so this asks a tool the
//! system already ships, exactly as `veilvoice-watch` asks the registry and
//! `drivers` asks `driverquery`.
//!
//! # What this can see, and what it cannot
//!
//! Processes belonging to the user running VeilVoice, and -- depending on the
//! platform and the privileges -- usually not much more. A program running as
//! another user or as a service may not appear at all.
//!
//! It sees a program that is **running**. It does not see what that program is
//! doing. Every caller has to phrase its findings accordingly, and [`SCOPE`]
//! is the wording to show rather than an invitation to invent one.
//!
//! `comm` on Linux is truncated to fifteen characters by the kernel, so any
//! table matched against this must carry a name of fifteen characters or fewer
//! for every program it expects to find there. A sixteen-character executable
//! would otherwise stop matching on one platform only, silently -- and the
//! tables that do this keep their own tests for it, next to the table, because
//! that is where somebody adds a row.
//!
//! # In plain words
//!
//! This asks your computer which programs are open right now, in the way each
//! operating system prefers to be asked. It is used by the parts of VeilVoice
//! that warn you when something able to record your screen, or watch your
//! typing, is running.
//!
//! Two honest limits. It can only see programs running as you -- something
//! hidden well enough, or running as the system, will not appear. And it only
//! knows a program is **open**, never that it is actually recording or
//! watching. Anything built on top of this has to say so in those words.
/// Every process name this build can see, lower-cased and without a path.
///
/// The second value is anything that went wrong. A list that came back short
/// because a tool failed is not a short list, and reporting the difference is
/// the whole reason this returns two things.
pub fn running() -> (Vec<String>, Vec<String>) {
#[cfg(target_os = "linux")]
{
linux()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
spawned()
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
(
Vec::new(),
vec!["no process reader is written for this platform".to_string()],
)
}
}
/// Walk `/proc` and read each process's own name.
#[cfg(target_os = "linux")]
fn linux() -> (Vec<String>, Vec<String>) {
let mut names = Vec::new();
let entries = match std::fs::read_dir("/proc") {
Ok(entries) => entries,
Err(error) => return (names, vec![format!("/proc: {error}")]),
};
for entry in entries.flatten() {
let path = entry.path();
// Only the numbered directories are processes. The rest of /proc is
// the kernel's own bookkeeping.
if !entry
.file_name()
.to_string_lossy()
.bytes()
.all(|b| b.is_ascii_digit())
{
continue;
}
// A process that exits between the walk and the read is ordinary, not
// an error worth reporting: by the time it could be mentioned it is
// already not running.
if let Ok(name) = std::fs::read_to_string(path.join("comm")) {
let name = name.trim().to_ascii_lowercase();
if !name.is_empty() {
names.push(name);
}
}
}
(names, Vec::new())
}
/// Ask the system's own process lister.
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn spawned() -> (Vec<String>, Vec<String>) {
#[cfg(target_os = "windows")]
let (program, arguments): (String, &[&str]) = {
// Absolute path, never a bare name: Windows searches the current
// directory before most of PATH, so a `tasklist.exe` sitting in the
// folder VeilVoice was unpacked into would answer this question
// instead. This is a security tool asking what is running.
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
(
format!(r"{root}\System32\tasklist.exe"),
&["/FO", "CSV", "/NH"],
)
};
#[cfg(target_os = "macos")]
let (program, arguments): (String, &[&str]) = ("/bin/ps".to_string(), &["-Ao", "comm="]);
let mut command = std::process::Command::new(&program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// Safe API, so it costs nothing against `#![forbid(unsafe_code)]`, and
// it is what stops a console flashing every time the monitor polls --
// the defect v0.1.10 shipped.
command.creation_flags(CREATE_NO_WINDOW);
}
let output = match command.args(arguments).output() {
Ok(output) => output,
Err(error) => return (Vec::new(), vec![format!("{program}: {error}")]),
};
if !output.status.success() {
return (
Vec::new(),
vec![format!(
"{program}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)],
);
}
// Lossy on purpose: a process whose name has a byte this cannot decode
// should still be compared rather than dropped.
let text = String::from_utf8_lossy(&output.stdout);
(parse(&text), Vec::new())
}
/// Pull process names out of a listing.
///
/// Handles both shapes with one function: `tasklist /FO CSV` quotes its first
/// field, and `ps -Ao comm=` gives a bare path per line. Taking the first
/// comma-separated field, stripping quotes, and then stripping any directory
/// covers both, and one parser cannot drift from the other.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
fn parse(text: &str) -> Vec<String> {
let mut names = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let first = line.split(',').next().unwrap_or(line);
let bare = first.trim().trim_matches('"');
let bare = bare.rsplit(['/', '\\']).next().unwrap_or(bare).trim();
if bare.is_empty() {
continue;
}
names.push(bare.to_ascii_lowercase());
}
names
}
/// What a reader has to be told, in the words to show them.
///
/// Here rather than in each caller so that two features cannot describe the
/// same limit two different ways.
pub const SCOPE: &str = "\
This sees programs running as you. Something running as another user, as a \
system service, or hidden well enough will not appear, so an empty list is not \
proof that nothing is there. It also sees only that a program is *open* -- \
never that it is recording, watching or doing anything at all.";
#[cfg(test)]
mod tests {
use super::*;
/// Real `tasklist /FO CSV /NH` output, kept verbatim.
const TASKLIST: &str = "\
\"System Idle Process\",\"0\",\"Services\",\"0\",\"8 K\"
\"explorer.exe\",\"5512\",\"Console\",\"1\",\"142,336 K\"
\"obs64.exe\",\"18244\",\"Console\",\"1\",\"221,904 K\"
\"veilvoice-gui.exe\",\"9012\",\"Console\",\"1\",\"78,220 K\"
";
/// Real `ps -Ao comm=` output, kept verbatim.
const PS: &str = "\
/sbin/launchd
/usr/libexec/logd
/Applications/OBS.app/Contents/MacOS/OBS
/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder
";
#[test]
fn a_windows_listing_gives_bare_lower_case_names() {
let names = parse(TASKLIST);
assert_eq!(names.len(), 4);
assert!(names.contains(&"obs64.exe".to_string()));
assert!(names.contains(&"explorer.exe".to_string()));
assert!(
names.contains(&"system idle process".to_string()),
"a name with spaces must survive: {names:?}"
);
}
#[test]
fn a_unix_listing_has_its_directories_stripped() {
let names = parse(PS);
assert_eq!(names.len(), 4);
assert!(names.contains(&"obs".to_string()), "{names:?}");
assert!(names.contains(&"launchd".to_string()));
assert!(
!names.iter().any(|name| name.contains('/')),
"a path survived: {names:?}"
);
}
#[test]
fn nothing_and_whitespace_produce_nothing() {
assert!(parse("").is_empty());
assert!(parse("\n\n \n").is_empty());
assert!(parse("\"\"\n").is_empty());
assert!(parse("/\n").is_empty());
}
/// The limit has to be stated outright, not hinted at. A caller that shows
/// an empty list without this is telling somebody their machine is clean.
#[test]
fn the_scope_note_says_what_an_empty_list_does_not_prove() {
let scope = SCOPE.to_lowercase();
assert!(scope.contains("not proof that nothing is there"), "{scope}");
assert!(scope.contains("never that it is recording"), "{scope}");
}
/// Asking the real machine must not panic, and must say something when it
/// cannot answer rather than returning a quiet empty list.
#[test]
fn listing_this_machine_does_not_panic() {
let (names, problems) = running();
if names.is_empty() {
assert!(
!problems.is_empty(),
"an empty list with no reason given is indistinguishable from an \
empty machine"
);
}
for name in &names {
assert_eq!(name, &name.to_ascii_lowercase());
}
}
}