use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountInfo {
pub source: String,
pub fstype: String,
}
impl MountInfo {
#[must_use]
pub fn is_docker_export(&self) -> bool {
self.fstype == "nfs"
&& self.source.split_once(":/").is_some_and(|(host, _)| {
host == "127.0.0.1" || host == arcbox_helper::HOSTS_ALIAS_NAME
})
}
#[must_use]
pub fn is_machine_export(&self) -> bool {
self.fstype == "nfs"
&& self
.source
.strip_suffix(":/")
.and_then(|host| host.parse::<Ipv4Addr>().ok())
.is_some_and(|host| !host.is_loopback())
}
}
const MAX_SYMLINK_HOPS: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountTable {
mounts: Vec<(PathBuf, MountInfo)>,
}
impl MountTable {
#[must_use]
pub fn read() -> Option<Self> {
let output = Command::new("/sbin/mount").output().ok()?;
output
.status
.success()
.then(|| Self::parse(&String::from_utf8_lossy(&output.stdout)))
}
#[must_use]
pub fn parse(listing: &str) -> Self {
Self {
mounts: listing.lines().filter_map(parse_mount_line).collect(),
}
}
#[must_use]
pub fn at(&self, path: &Path) -> Option<MountInfo> {
let mut path = path.to_path_buf();
for _ in 0..MAX_SYMLINK_HOPS {
let candidate = canonical_mount_point(&path)?;
if let Some(info) = self.find(&candidate) {
return Some(info);
}
let meta = std::fs::symlink_metadata(&candidate).ok()?;
if !meta.file_type().is_symlink() {
return None;
}
path = candidate
.parent()?
.join(std::fs::read_link(&candidate).ok()?);
}
None
}
#[must_use]
pub fn under(&self, root: &Path) -> Vec<(PathBuf, MountInfo)> {
let Some(root) = canonical_mount_point(root) else {
return Vec::new();
};
let mut mounts: Vec<(PathBuf, MountInfo)> = self
.mounts
.iter()
.filter(|(mountpoint, _)| mountpoint.starts_with(&root) && *mountpoint != root)
.cloned()
.collect();
mounts.sort_by_key(|(mountpoint, _)| std::cmp::Reverse(mountpoint.components().count()));
mounts
}
fn find(&self, target: &Path) -> Option<MountInfo> {
self.mounts
.iter()
.find(|(mountpoint, _)| mountpoint == target)
.map(|(_, info)| info.clone())
}
}
fn canonical_mount_point(path: &Path) -> Option<PathBuf> {
let parent = std::fs::canonicalize(path.parent()?).ok()?;
Some(parent.join(path.file_name()?))
}
fn parse_mount_line(line: &str) -> Option<(PathBuf, MountInfo)> {
let (source, rest) = line.split_once(" on ")?;
let (mountpoint, suffix) = rest.split_once(" (")?;
let fstype = suffix
.split([',', ')'])
.next()
.unwrap_or_default()
.trim()
.to_string();
Some((
PathBuf::from(mountpoint),
MountInfo {
source: source.to_string(),
fstype,
},
))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{MountInfo, MountTable, parse_mount_line};
fn nfs(source: &str) -> MountInfo {
MountInfo {
source: source.to_string(),
fstype: "nfs".to_string(),
}
}
#[test]
fn the_docker_export_is_known_by_its_loopback_or_alias_source() {
for source in [
"127.0.0.1:/",
"ArcBox:/",
"ArcBox:/containerd",
"127.0.0.1:/containerd",
] {
assert!(nfs(source).is_docker_export(), "{source}");
assert!(!nfs(source).is_machine_export(), "{source}");
}
assert!(!nfs("fileserver:/export/home").is_docker_export());
assert!(!nfs("192.168.64.7:/").is_docker_export());
let smb = MountInfo {
source: "//user@server/share".to_string(),
fstype: "smbfs".to_string(),
};
assert!(!smb.is_docker_export());
assert!(!smb.is_machine_export());
}
#[test]
fn only_an_nfs_root_from_an_address_is_a_machine_export() {
assert!(nfs("192.168.64.7:/").is_machine_export());
for source in [
"ArcBox:/",
"127.0.0.1:/",
"fileserver:/export",
"192.168.64.7:/srv",
] {
assert!(!nfs(source).is_machine_export(), "{source}");
}
}
#[test]
fn a_mount_point_resolves_through_symlinks_above_and_at_it() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("real")).unwrap();
std::os::unix::fs::symlink("real", dir.path().join("link")).unwrap();
let canonical = std::fs::canonicalize(dir.path()).unwrap().join("real");
let table = MountTable::parse(&format!(
"/dev/disk3s1 on / (apfs, local, journaled)\n\
192.168.64.3:/ on {} (nfs, nodev, nosuid, mounted by Xuan)\n",
canonical.display()
));
let info = nfs("192.168.64.3:/");
assert_eq!(table.at(&dir.path().join("real")), Some(info.clone()));
assert_eq!(table.at(&dir.path().join("link")), Some(info));
assert_eq!(table.at(&dir.path().join("missing")), None);
assert_eq!(table.at(dir.path()), None, "a plain directory");
}
#[test]
fn parse_mount_line_extracts_source_and_fstype() {
let line =
"127.0.0.1:/run/arcbox/nfs-export/docker on /Users/t/ArcBox (nfs, nodev, read-only)";
let (mountpoint, info) = parse_mount_line(line).expect("line should parse");
assert_eq!(mountpoint, Path::new("/Users/t/ArcBox"));
assert_eq!(info, nfs("127.0.0.1:/run/arcbox/nfs-export/docker"));
}
#[test]
fn find_matches_the_canonical_mount_point_exactly() {
let table = MountTable::parse(
"/dev/disk3s1 on / (apfs, local, journaled)\n\
ArcBox:/ on /private/var/folders/x/arcbox-e2e/ArcBox (nfs, nodev, read-only)\n\
ArcBox:/containerd on /private/var/folders/x/arcbox-e2e/ArcBox/containerd (nfs, automounted)\n",
);
assert_eq!(
table.find(Path::new("/private/var/folders/x/arcbox-e2e/ArcBox")),
Some(nfs("ArcBox:/"))
);
assert_eq!(
table.find(Path::new("/private/var/folders/x/arcbox-e2e")),
None
);
assert_eq!(
table.find(Path::new("/var/folders/x/arcbox-e2e/ArcBox")),
None,
"the caller canonicalizes; the uncanonical spelling is not in the table"
);
}
#[test]
fn under_lists_the_descendants_deepest_first() {
let table = MountTable::parse(
"/dev/disk3s1 on / (apfs, local, journaled)\n\
ArcBox:/ on /Users/ArcBox (nfs, nodev, read-only)\n\
ArcBox:/ on /Users/ArcBox/docker (nfs, nodev, read-only)\n\
192.168.64.7:/ on /Users/ArcBox/machines/ubuntu (nfs, nodev)\n\
ArcBox:/containerd on /Users/ArcBox/docker/containerd (nfs, automounted)\n\
192.168.64.8:/ on /Users/ArcBoxMachines/alpine (nfs, nodev)\n",
);
let under: Vec<PathBuf> = table
.under(Path::new("/Users/ArcBox"))
.into_iter()
.map(|(mountpoint, _)| mountpoint)
.collect();
let position = |path: &str| {
under
.iter()
.position(|mountpoint| mountpoint == Path::new(path))
.unwrap_or_else(|| panic!("{path} is under the root: {under:?}"))
};
assert_eq!(under.len(), 3, "{under:?}");
assert!(
position("/Users/ArcBox/docker/containerd") < position("/Users/ArcBox/docker"),
"a child is released before the mount it hangs under: {under:?}"
);
position("/Users/ArcBox/machines/ubuntu");
assert_eq!(
table
.under(Path::new("/Users/ArcBoxMachines"))
.into_iter()
.map(|(_, info)| info.source)
.collect::<Vec<_>>(),
["192.168.64.8:/"]
);
assert_eq!(
table.under(Path::new("/Users/Other")).len(),
0,
"a root nothing is mounted under"
);
}
}