crates/veilvoice-watch/src/linux.rs

what this file is for · veilvoice-watch · 201 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
//! Linux detection, via open file handles in `/proc`.
//!
//! # How it works
//!
//! A process using the microphone has a file descriptor open on an ALSA PCM
//! capture node, `/dev/snd/pcmC0D0c`, where the trailing `c` means capture as
//! opposed to `p` for playback. A process using the camera has one open on
//! `/dev/video*`. Walking `/proc/*/fd` and resolving the symlinks finds them,
//! along with the PID and the process name, with no dependency and no daemon.
//!
//! Capture and playback are distinguished deliberately. Treating every open
//! `/dev/snd` handle as microphone use would report a music player as
//! listening to you, and a monitor that cries wolf gets ignored, which is the
//! worst possible outcome for this feature.
//!
//! # Sound servers
//!
//! On most desktops PipeWire or PulseAudio owns the hardware, so the process
//! holding the PCM node is the *server*, not the application behind it. That is
//! reported honestly rather than hidden: the server appearing means something
//! is capturing, and where the client can be identified from the ALSA
//! `/proc/asound` bookkeeping, it is named too.
//!
//! # The permission boundary
//!
//! `/proc/<pid>/fd` is readable only by the process owner and root. Without
//! root you therefore see your own processes; another user's are invisible.
//! That is a kernel boundary, not a gap in this code, and [`crate::support`]
//! says so rather than letting an empty list imply an empty machine.
//!
//! # In plain words
//!
//! Finds out which programs are using the microphone or camera on Linux, by
//! looking at which of them have the device open.
//!
//! That is exactly what the system already knows and nothing has to be installed
//! to ask. It sees what your own account can see, so something running as another
//! user may not appear, and an empty list is not proof of a quiet machine.

use crate::{DeviceKind, DeviceUse, Error};
use std::fs;
use std::path::Path;
use std::time::{Duration, SystemTime};


pub fn scan() -> Result<Vec<DeviceUse>, Error> {
    let mut found = Vec::new();

    let entries = match fs::read_dir("/proc") {
        Ok(e) => e,
        Err(e) => return Err(Error::Io(e)),
    };

    for entry in entries.flatten() {
        let Some(pid) = entry
            .file_name()
            .to_str()
            .and_then(|n| n.parse::<u32>().ok())
        else {
            continue;
        };
        let fd_dir = entry.path().join("fd");
        let Ok(handles) = fs::read_dir(&fd_dir) else {
            // Not ours to inspect, or the process exited between listing and
            // opening. Either way, skip it rather than fail the whole scan.
            continue;
        };

        let mut seen = Vec::new();
        for handle in handles.flatten() {
            let Ok(target) = fs::read_link(handle.path()) else {
                continue;
            };
            let Some(kind) = classify(&target) else {
                continue;
            };
            if seen.contains(&kind) {
                continue; // one entry per process per device kind
            }
            seen.push(kind);

            found.push(DeviceUse {
                kind,
                app: process_name(pid).unwrap_or_else(|| format!("pid {pid}")),
                path: fs::read_link(entry.path().join("exe"))
                    .ok()
                    .map(|p| p.display().to_string()),
                pid: Some(pid),
                since: started_at(&entry.path()),
                device: Some(target.display().to_string()),
            });
        }
    }

    found.sort_by_key(|u| (u.kind, u.pid));
    Ok(found)
}



/// Decide whether an open handle means capture.
///
/// Returns `None` for playback devices, control nodes and everything else, so
/// a music player is never mistaken for something listening.
fn classify(target: &Path) -> Option<DeviceKind> {
    let path = target.to_str()?;

    if let Some(node) = path.strip_prefix("/dev/snd/") {
        // pcmC0D0c is capture, pcmC0D0p is playback. Only the former counts.
        if node.starts_with("pcm") && node.ends_with('c') {
            return Some(DeviceKind::Microphone);
        }
        return None;
    }

    if path.starts_with("/dev/video") {
        // /dev/video1 may be a metadata node rather than a capture stream, but
        // an open handle on it still means the camera subsystem is in use, and
        // over-reporting a camera is the safer direction to err.
        return Some(DeviceKind::Camera);
    }

    None
}



fn process_name(pid: u32) -> Option<String> {
    let comm = fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
    let name = comm.trim();
    (!name.is_empty()).then(|| name.to_string())
}



/// When the process started, from the modification time of its `/proc` entry.
///
/// This is the process start time, not the moment it opened the device. The
/// kernel does not record the latter. It is reported as the best available
/// answer rather than omitted, since "running since" is still useful context.
fn started_at(proc_dir: &Path) -> Option<SystemTime> {
    fs::metadata(proc_dir).ok()?.modified().ok()
}



/// Unused today; kept because a future PipeWire client lookup will want it.
#[allow(dead_code)]
fn approx_now_minus(seconds: u64) -> Option<SystemTime> {
    SystemTime::now().checked_sub(Duration::from_secs(seconds))
}


#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn capture_nodes_are_recognised() {
        assert_eq!(
            classify(Path::new("/dev/snd/pcmC0D0c")),
            Some(DeviceKind::Microphone)
        );
        assert_eq!(
            classify(Path::new("/dev/snd/pcmC1D2c")),
            Some(DeviceKind::Microphone)
        );
    }

    /// The distinction that keeps this feature trustworthy: playing music must
    /// never be reported as using the microphone.
    #[test]
    fn playback_nodes_are_not_microphone_use() {
        assert_eq!(classify(Path::new("/dev/snd/pcmC0D0p")), None);
        assert_eq!(classify(Path::new("/dev/snd/controlC0")), None);
        assert_eq!(classify(Path::new("/dev/snd/timer")), None);
        assert_eq!(classify(Path::new("/dev/snd/seq")), None);
    }

    #[test]
    fn video_nodes_are_camera_use() {
        assert_eq!(classify(Path::new("/dev/video0")), Some(DeviceKind::Camera));
        assert_eq!(
            classify(Path::new("/dev/video12")),
            Some(DeviceKind::Camera)
        );
    }

    #[test]
    fn ordinary_files_are_ignored() {
        for path in [
            "/home/user/notes.txt",
            "/dev/null",
            "/dev/urandom",
            "/tmp/x",
        ] {
            assert_eq!(classify(Path::new(path)), None, "{path}");
        }
    }

    /// Scanning must survive a machine where most of /proc is unreadable.
    #[test]
    fn scanning_does_not_fail_on_inaccessible_processes() {
        let entries = scan().expect("a scan should not fail outright");
        for entry in &entries {
            assert!(entry.pid.is_some(), "Linux always knows the PID");
            assert!(!entry.app.is_empty());
        }
    }
}