use anyhow::{Context, Result};
use std::path::PathBuf;
pub(super) fn enumerate_mounts(include_network: bool) -> Result<Vec<PathBuf>> {
#[cfg(target_os = "linux")]
{
linux_mounts(include_network)
}
#[cfg(target_os = "macos")]
{
macos_mounts(include_network)
}
#[cfg(target_os = "windows")]
{
windows_drives(include_network)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Ok(vec![std::env::current_dir()?])
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct MountFilters {
#[serde(default)]
skip_fs_types: Vec<String>,
#[serde(default)]
skip_path_prefixes: Vec<String>,
#[serde(default)]
network_fs_types: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindowsDriveClass {
Local,
Network,
Unsupported,
}
const BUNDLED_MOUNT_FILTERS: &str = include_str!("../../../data/scan_system/mount_filters.toml");
fn load_mount_filters() -> Result<MountFilters> {
let mut filters: MountFilters = toml::from_str(BUNDLED_MOUNT_FILTERS)
.context("parse bundled scan_system/mount_filters.toml (build bug)")?;
let Some(user_path) = dirs::config_dir().map(|d| d.join("keyhog/mount_filters.toml")) else {
return Ok(filters);
};
match std::fs::read_to_string(&user_path) {
Ok(text) => {
let user: MountFilters = toml::from_str(&text)
.with_context(|| format!("parse mount filters {}", user_path.display()))?;
filters.skip_fs_types.extend(user.skip_fs_types);
filters.skip_path_prefixes.extend(user.skip_path_prefixes);
filters.network_fs_types.extend(user.network_fs_types);
Ok(filters)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(filters),
Err(error) => Err(anyhow::Error::new(error))
.with_context(|| format!("read mount filters {}", user_path.display())),
}
}
fn include_windows_drive(
root: &str,
drive_class: WindowsDriveClass,
include_network: bool,
filters: &MountFilters,
) -> bool {
if windows_root_matches_any_skip_prefix(root, &filters.skip_path_prefixes) {
return false;
}
match drive_class {
WindowsDriveClass::Local => true,
WindowsDriveClass::Network => include_network,
WindowsDriveClass::Unsupported => false,
}
}
fn windows_root_matches_any_skip_prefix(root: &str, prefixes: &[String]) -> bool {
let normalized_root = root.replace('/', "\\");
prefixes.iter().any(|prefix| {
let normalized_prefix = prefix.replace('/', "\\");
normalized_root
.get(..normalized_prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(&normalized_prefix))
})
}
#[cfg(target_os = "linux")]
fn linux_mounts(include_network: bool) -> Result<Vec<PathBuf>> {
let filters = load_mount_filters()?;
let skip_fs_types: std::collections::HashSet<&str> =
filters.skip_fs_types.iter().map(String::as_str).collect();
let network_fs_types: std::collections::HashSet<&str> = filters
.network_fs_types
.iter()
.map(String::as_str)
.collect();
let mounts_text = std::fs::read_to_string("/proc/mounts").context("read /proc/mounts")?;
let mut roots = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in mounts_text.lines() {
let mut fields = line.split_whitespace();
let _device = fields.next();
let target = match fields.next() {
Some(t) => t,
None => continue,
};
let fstype = fields.next().unwrap_or(""); if skip_fs_types.contains(fstype) {
continue;
}
if !include_network && network_fs_types.contains(fstype) {
continue;
}
let Some(decoded) = decoded_mount_target_if_included(target, &filters)? else {
continue;
};
if seen.insert(decoded.clone()) {
roots.push(PathBuf::from(decoded));
}
}
Ok(dedupe_mount_roots(roots))
}
fn dedupe_mount_roots(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
roots.sort_by_key(|p| p.as_os_str().len());
let mut deduped: Vec<PathBuf> = Vec::new();
for r in roots {
let already_covered = deduped.iter().any(|d| r.starts_with(d) && r != *d);
if !already_covered {
deduped.push(r);
}
}
deduped
}
#[cfg(target_os = "linux")]
fn decoded_mount_target_if_included(
target: &str,
filters: &MountFilters,
) -> Result<Option<String>> {
let decoded = decode_octal_escapes(target)
.with_context(|| format!("decode /proc/mounts target {target:?}"))?;
if filters
.skip_path_prefixes
.iter()
.any(|prefix| decoded.starts_with(prefix))
{
return Ok(None);
}
Ok(Some(decoded))
}
#[cfg(target_os = "linux")]
fn decode_octal_escapes(s: &str) -> Result<String> {
let mut out: Vec<u8> = Vec::with_capacity(s.len());
let mut bytes = s.bytes().peekable();
while let Some(b) = bytes.next() {
if b == b'\\' {
let mut octal = String::with_capacity(3);
for _ in 0..3 {
match bytes.peek() {
Some(&d) if d.is_ascii_digit() => {
octal.push(d as char);
bytes.next();
}
_ => break,
}
}
if octal.len() == 3 {
let byte = u8::from_str_radix(&octal, 8)
.with_context(|| format!("invalid octal mount escape \\{octal}"))?;
out.push(byte);
continue;
}
anyhow::bail!("incomplete mount escape \\{octal}");
} else {
out.push(b);
}
}
String::from_utf8(out)
.with_context(|| format!("decoded /proc/mounts target is not UTF-8: {s:?}"))
}
#[cfg(target_os = "macos")]
fn macos_mounts(include_network: bool) -> Result<Vec<PathBuf>> {
let filters = load_mount_filters()?;
let bin = keyhog_core::resolve_safe_bin("mount").ok_or_else(|| {
anyhow::anyhow!(
"scan-system: trusted mount(8) binary not found. Install the system mount tool or \
add its absolute directory to [system].trusted_bin_dirs in .keyhog.toml"
)
})?;
let output = std::process::Command::new(&bin)
.output()
.context("run mount(8)")?;
let text = String::from_utf8_lossy(&output.stdout);
Ok(parse_macos_mount_table(&text, include_network, &filters))
}
fn parse_macos_mount_table(
text: &str,
include_network: bool,
filters: &MountFilters,
) -> Vec<PathBuf> {
let skip_fs_types: std::collections::HashSet<&str> =
filters.skip_fs_types.iter().map(String::as_str).collect();
let network_fs_types: std::collections::HashSet<&str> = filters
.network_fs_types
.iter()
.map(String::as_str)
.collect();
let mut roots = Vec::new();
for line in text.lines() {
if let Some(on_idx) = line.rfind(" on ") {
let rest = &line[on_idx + 4..];
if let Some(paren_idx) = rest.rfind(" (") {
let path = &rest[..paren_idx];
let fs_info = &rest[paren_idx + 2..];
let fstype = fs_info.split(',').next().unwrap_or("").trim(); if skip_fs_types.contains(fstype) {
continue;
}
if !include_network && network_fs_types.contains(fstype) {
continue;
}
if filters
.skip_path_prefixes
.iter()
.any(|prefix| path.starts_with(prefix))
{
continue;
}
roots.push(PathBuf::from(path));
}
}
}
dedupe_mount_roots(roots)
}
pub(crate) mod testing {
use super::{include_windows_drive, parse_macos_mount_table, MountFilters, WindowsDriveClass};
use std::path::PathBuf;
pub(crate) fn parse_macos_mount_table_for_test(
text: &str,
include_network: bool,
) -> Result<Vec<PathBuf>, toml::de::Error> {
let filters: MountFilters = toml::from_str(super::BUNDLED_MOUNT_FILTERS)?;
Ok(parse_macos_mount_table(text, include_network, &filters))
}
pub(crate) fn windows_drive_filter_decisions_for_test(
) -> Result<(bool, bool, bool, bool), toml::de::Error> {
let filters: MountFilters = toml::from_str(super::BUNDLED_MOUNT_FILTERS)?;
let local_without_network =
include_windows_drive("C:\\", WindowsDriveClass::Local, false, &filters);
let remote_without_network =
include_windows_drive("Z:\\", WindowsDriveClass::Network, false, &filters);
let remote_with_network =
include_windows_drive("Z:\\", WindowsDriveClass::Network, true, &filters);
let unsupported =
include_windows_drive("Q:\\", WindowsDriveClass::Unsupported, true, &filters);
Ok((
local_without_network,
remote_without_network,
remote_with_network,
unsupported,
))
}
pub(crate) fn windows_drive_skip_prefix_decisions_for_test() -> (bool, bool) {
let prefixes = vec!["z:/".to_string()];
(
super::windows_root_matches_any_skip_prefix("Z:\\", &prefixes),
super::windows_root_matches_any_skip_prefix("C:\\", &prefixes),
)
}
#[cfg(target_os = "linux")]
pub(crate) fn decoded_mount_target_if_included_for_test(
target: &str,
skip_path_prefixes: Vec<String>,
) -> anyhow::Result<Option<String>> {
let filters = MountFilters {
skip_path_prefixes,
..MountFilters::default()
};
super::decoded_mount_target_if_included(target, &filters)
}
}
#[cfg(target_os = "windows")]
fn windows_drives(include_network: bool) -> Result<Vec<PathBuf>> {
use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives};
use windows_sys::Win32::System::WindowsProgramming::{
DRIVE_FIXED, DRIVE_RAMDISK, DRIVE_REMOTE,
};
let filters = load_mount_filters()?;
let mask = unsafe { GetLogicalDrives() };
if mask == 0 {
return Err(std::io::Error::last_os_error()).context("enumerate Windows logical drives");
}
let mut drives = Vec::new();
for (idx, letter) in (b'A'..=b'Z').enumerate() {
if (mask & (1 << idx)) == 0 {
continue;
}
let root = format!("{}:\\", letter as char);
let wide: Vec<u16> = root.encode_utf16().chain(std::iter::once(0)).collect();
let drive_class = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
DRIVE_FIXED | DRIVE_RAMDISK => WindowsDriveClass::Local,
DRIVE_REMOTE => WindowsDriveClass::Network,
_ => WindowsDriveClass::Unsupported,
};
if include_windows_drive(&root, drive_class, include_network, &filters) {
drives.push(PathBuf::from(root));
}
}
Ok(dedupe_mount_roots(drives))
}