use std::path::Path;
use crate::scan::ProtectedPath;
#[derive(Debug, PartialEq, Eq)]
pub struct Finding {
pub subject: String,
pub detail: String,
}
pub fn audit(root: &Path, protected: &[ProtectedPath]) -> Vec<Finding> {
let mut findings = hard_links(protected);
findings.extend(second_mounts(root));
findings.extend(already_open_for_writing(protected));
findings
}
#[cfg(windows)]
fn already_open_for_writing(protected: &[ProtectedPath]) -> Vec<Finding> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const IN_USE: [i32; 2] = [32, 33];
protected
.iter()
.filter(|path| !path.is_dir)
.filter(|path| {
std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&path.absolute)
.err()
.and_then(|error| error.raw_os_error())
.is_some_and(|code| IN_USE.contains(&code))
})
.map(|path| Finding {
subject: path.relative.clone(),
detail: "is held open by another process, so it cannot be locked — protect \
the files a program owns, not the ones it is using"
.to_string(),
})
.collect()
}
#[cfg(not(windows))]
fn already_open_for_writing(_protected: &[ProtectedPath]) -> Vec<Finding> {
Vec::new()
}
#[cfg(unix)]
fn hard_links(protected: &[ProtectedPath]) -> Vec<Finding> {
use std::os::unix::fs::MetadataExt;
protected
.iter()
.filter(|path| !path.is_dir)
.filter_map(|path| {
let links = std::fs::metadata(&path.absolute).ok()?.nlink();
(links > 1).then(|| Finding {
subject: path.relative.clone(),
detail: format!(
"has {links} hard links; the other names are not protected and \
writing one changes this file"
),
})
})
.collect()
}
#[cfg(not(unix))]
fn hard_links(_protected: &[ProtectedPath]) -> Vec<Finding> {
Vec::new()
}
#[cfg(target_os = "linux")]
fn second_mounts(root: &Path) -> Vec<Finding> {
let Ok(table) = std::fs::read_to_string("/proc/self/mountinfo") else {
return Vec::new();
};
second_mounts_from(root, &table)
}
#[cfg(not(target_os = "linux"))]
fn second_mounts(_root: &Path) -> Vec<Finding> {
Vec::new()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
struct Mount<'a> {
device: &'a str,
root: &'a str,
point: &'a str,
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse(table: &str) -> Vec<Mount<'_>> {
table
.lines()
.filter_map(|line| {
let mut fields = line.split(' ');
let root = fields.nth(3)?;
let point = fields.next()?;
let device = fields.skip_while(|field| *field != "-").nth(2)?;
Some(Mount {
device,
root,
point,
})
})
.collect()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn second_mounts_from(root: &Path, table: &str) -> Vec<Finding> {
let mounts = parse(table);
let Some(root) = root.to_str() else {
return Vec::new();
};
let Some(home) = mounts
.iter()
.filter(|mount| under(root, mount.point))
.max_by_key(|mount| mount.point.len())
else {
return Vec::new();
};
let inside = join(home.root, strip(root, home.point));
mounts
.iter()
.filter(|mount| mount.point != home.point)
.filter(|mount| mount.device == home.device)
.filter(|mount| under(&inside, mount.root))
.map(|mount| Finding {
subject: join(mount.point, strip(&inside, mount.root)),
detail: "is a second path to this project; writes through it are not \
restricted by either backend"
.to_string(),
})
.collect()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn under(path: &str, prefix: &str) -> bool {
if prefix == "/" {
return true;
}
let prefix = prefix.trim_end_matches('/');
path == prefix || path.starts_with(&format!("{prefix}/"))
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip<'a>(path: &'a str, prefix: &str) -> &'a str {
if prefix == "/" {
return path.trim_start_matches('/');
}
path.strip_prefix(prefix.trim_end_matches('/'))
.unwrap_or("")
.trim_start_matches('/')
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn join(base: &str, remainder: &str) -> String {
let base = base.trim_end_matches('/');
if remainder.is_empty() {
return if base.is_empty() {
"/".into()
} else {
base.into()
};
}
format!("{base}/{remainder}")
}
#[cfg(test)]
mod tests {
use super::*;
const TABLE: &str = "\
25 0 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw
26 25 8:1 /home/dev/proj /mnt/copy rw,relatime shared:1 - ext4 /dev/sda1 rw
27 25 0:22 / /proc rw,relatime shared:2 - proc proc rw";
#[test]
fn finds_a_bind_mount_of_the_project() {
let findings = second_mounts_from(Path::new("/home/dev/proj"), TABLE);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].subject, "/mnt/copy");
}
#[test]
fn finds_the_project_inside_a_bind_mounted_parent() {
let findings = second_mounts_from(Path::new("/home/dev/proj/src"), TABLE);
assert_eq!(findings[0].subject, "/mnt/copy/src");
}
#[test]
fn a_project_that_is_mounted_once_reports_nothing() {
let findings = second_mounts_from(Path::new("/home/dev/other"), TABLE);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn a_different_filesystem_at_another_point_is_not_a_second_path() {
let table = "\
25 0 8:1 / / rw - ext4 /dev/sda1 rw
26 25 8:2 / /mnt/other rw - ext4 /dev/sdb1 rw";
assert!(second_mounts_from(Path::new("/home/dev/proj"), table).is_empty());
}
#[test]
fn prefix_matching_respects_component_boundaries() {
assert!(under("/a/b", "/a"));
assert!(under("/a", "/a"));
assert!(under("/anything", "/"));
assert!(!under("/ab", "/a"), "/ab is not inside /a");
}
}