use std::ffi::CString;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use log::{debug, info, warn};
const POLL_INTERVAL: Duration = Duration::from_millis(20);
pub fn is_mountpoint(path: &Path) -> bool {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(e) => return e.raw_os_error() == Some(libc::ENOTCONN),
};
if !metadata.is_dir() {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
match std::fs::metadata(parent) {
Ok(parent) => parent.dev() != metadata.dev(),
Err(_) => false,
}
}
pub fn force_unmount(path: &Path) -> io::Result<()> {
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount path has a NUL byte"))?;
#[cfg(target_os = "linux")]
let detached = unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) } == 0;
#[cfg(target_os = "macos")]
let detached = unsafe { libc::unmount(c_path.as_ptr(), libc::MNT_FORCE) } == 0;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let detached = false;
if detached {
return Ok(());
}
let syscall_error = io::Error::last_os_error();
debug!(
"Unmounting {} directly failed ({syscall_error}); falling back to the fusermount helper",
path.display()
);
for helper in ["fusermount3", "fusermount"] {
let run = Command::new(helper)
.args(["-u", "-q", "-z", "--"])
.arg(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
match run {
Ok(status) if status.success() => return Ok(()),
Ok(status) => debug!("{helper} exited {status} for {}", path.display()),
Err(e) => debug!("Can't run {helper}: {e}"),
}
}
if is_mountpoint(path) {
Err(syscall_error)
} else {
Ok(())
}
}
pub fn wait_until_unmounted(path: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if !is_mountpoint(path) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
pub fn clean_stale_mounts(root: &Path) -> Vec<PathBuf> {
let mut cleaned = Vec::new();
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return cleaned,
Err(e) => {
warn!("Can't check {} for stale mounts: {e}", root.display());
return cleaned;
}
};
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let path = entry.path();
if is_mountpoint(&path) {
info!("Cleaning up a mount left behind at {}", path.display());
if let Err(e) = force_unmount(&path) {
warn!("Can't unmount the leftover at {}: {e}", path.display());
continue;
}
if !wait_until_unmounted(&path, Duration::from_secs(5)) {
warn!("{} is still mounted after unmounting it", path.display());
continue;
}
cleaned.push(path.clone());
}
if let Err(e) = std::fs::remove_dir(&path) {
debug!("Leaving {} in place: {e}", path.display());
}
}
cleaned
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_directory_is_not_a_mountpoint() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child");
std::fs::create_dir(&child).unwrap();
assert!(!is_mountpoint(&child));
}
#[test]
fn a_missing_path_is_not_a_mountpoint() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_mountpoint(&dir.path().join("nope")));
}
#[test]
fn a_file_is_not_a_mountpoint() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file");
std::fs::write(&file, b"x").unwrap();
assert!(!is_mountpoint(&file));
}
#[test]
fn a_missing_root_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(clean_stale_mounts(&dir.path().join("never-created")).is_empty());
}
#[test]
fn sweeping_removes_empty_leftovers_but_keeps_directories_with_files() {
let root = tempfile::tempdir().unwrap();
let empty = root.path().join("ABC123");
let occupied = root.path().join("not-ours");
std::fs::create_dir(&empty).unwrap();
std::fs::create_dir(&occupied).unwrap();
std::fs::write(occupied.join("keep"), b"x").unwrap();
assert!(clean_stale_mounts(root.path()).is_empty());
assert!(!empty.exists());
assert!(occupied.exists());
}
#[test]
fn waiting_on_a_path_that_was_never_mounted_returns_at_once() {
let dir = tempfile::tempdir().unwrap();
assert!(wait_until_unmounted(dir.path(), Duration::from_millis(1)));
}
}