obliterate 1.0.0

Force-remove Files and Directories on Linux Including Paths with 000 Permissions.
Documentation
//! Mount-point detection and lazy unmounting for Linux.
//!
//! Before obliterating a directory tree, every mount-point nested inside it
//! must be unmounted — otherwise `unlinkat` on a mount-point itself succeeds
//! (it removes the directory from the parent namespace) but the filesystem
//! underneath stays alive, which is almost always a disaster in a rootfs
//! scenario.
//!
//! # Unmount strategy (tried in order, non-root friendly)
//!
//! 1. **`umount2(MNT_DETACH)` syscall** via `libc::umount2` —
//!    succeeds when the process has `CAP_SYS_ADMIN` (running as root) or when
//!    the mount lives in a user-namespace owned by this process.
//!
//! 2. **`fusermount3 -u -z`** — for any `fuse/*` filesystem, `fusermount3` is
//!    a setuid-root binary that verifies the mount belongs to the calling user
//!    and calls `umount2(MNT_DETACH)` on its behalf. This is the only reliable
//!    non-root path for FUSE mounts (sshfs, rclone, gvfs, …).
//!
//! 3. **`fusermount -u -z`** — fallback alias for older systems that ship
//!    `fusermount` (version 2) rather than `fusermount3`.
//!
//! 4. **[`Error::UnmountFailed`]** — none of the above worked;
//!    the caller must arrange unmounting externally (e.g. `sudo umount`).

use std::ffi::CString;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::error::{Error, Result};

/// `MNT_DETACH` flag for `umount2(2)` — immediately detaches from the namespace
/// but keeps open handles functional until they are closed.
const MNT_DETACH: libc::c_int = 2;

/// A single entry from `/proc/self/mountinfo` whose mountpoint is at or under
/// the path being obliterated.
#[derive(Debug, Clone)]
pub struct MountPoint {
    /// Absolute path where this filesystem is mounted.
    pub path: PathBuf,
    /// Filesystem type as reported by the kernel (e.g. `"fuse.sshfs"`, `"ext4"`).
    pub fstype: String,
}

/// Internal error states for the `fusermount` process wrapper.
enum FusermountError {
    /// Neither `fusermount3` nor `fusermount` was found in the system PATH.
    NotFound,
    /// The command was found but execution failed with an error message.
    Failed(String),
}

/// Return all mount-points that are at or under `root`, sorted **the deepest first**
/// so that nested mounts are unmounted before their parents.
///
/// # Arguments
///
/// * `root` - The base path to scan for nested mount-points.
///
/// # Returns
///
/// Returns a [`Result`] containing a vector of [`MountPoint`] objects.
pub fn mounts_under(root: &Path) -> Result<Vec<MountPoint>> {
    let root_canon = match root.canonicalize() {
        Ok(p) => p,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(Error::IoError(e)),
    };

    let info = match std::fs::read_to_string("/proc/self/mountinfo") {
        Ok(s) => s,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(Error::IoError(e)),
    };

    let mut mounts: Vec<MountPoint> = info
        .lines()
        .filter_map(parse_mountinfo_line)
        .filter(|mp| mp.path == root_canon || mp.path.starts_with(&root_canon))
        .collect();

    // Deepest paths first so children are unmounted before parents.
    mounts.sort_by(|a, b| b.path.as_os_str().len().cmp(&a.path.as_os_str().len()));

    Ok(mounts)
}

/// Attempt to lazily unmount `mp` using the best available strategy.
///
/// "Lazy" (`MNT_DETACH`) means active file handles continue to work until
/// closed, but the mount-point is immediately detached from the namespace tree —
/// which is exactly what is needed before deleting the directory.
///
/// # Arguments
///
/// * `mp` - The specific mount-point metadata to be unmounted.
///
/// # Returns
///
/// Returns `Ok(())` if the unmounting was successful.
pub fn unmount_lazy(mp: &MountPoint) -> Result<()> {
    match try_umount2_detach(&mp.path) {
        Ok(()) => return Ok(()),
        Err(e) if is_permission_error(&e) => {}
        Err(e) => return Err(Error::IoError(e)),
    }

    if is_fuse(&mp.fstype) {
        return match try_fusermount(&mp.path) {
            Ok(()) => Ok(()),
            Err(FusermountError::NotFound) => Err(Error::UnmountFailed {
                path: mp.path.clone(),
                fstype: mp.fstype.clone(),
                reason: "fusermount3 not found; install fuse3 or run as root".into(),
            }),
            Err(FusermountError::Failed(msg)) => Err(Error::UnmountFailed {
                path: mp.path.clone(),
                fstype: mp.fstype.clone(),
                reason: format!("fusermount3 failed: {msg}"),
            }),
        };
    }

    Err(Error::UnmountFailed {
        path: mp.path.clone(),
        fstype: mp.fstype.clone(),
        reason: format!(
            "filesystem type '{}' requires CAP_SYS_ADMIN to unmount",
            mp.fstype
        ),
    })
}

/// Parse a single line of `/proc/self/mountinfo`.
///
/// The optional fields section (between `mountoptions` and the bare `-` token)
/// may be empty, one field, or many — so we locate the `-` separator by
/// scanning for it as a standalone token rather than as a substring.
///
/// # Arguments
///
/// * `line` - A raw line string from the mountinfo file.
///
/// # Returns
///
/// Returns `Some(MountPoint)` if successfully parsed, or `None` if invalid.
fn parse_mountinfo_line(line: &str) -> Option<MountPoint> {
    let tokens: Vec<&str> = line.split(' ').collect();

    let mountpoint_raw = tokens.get(4)?;
    let sep_pos = tokens.iter().position(|&t| t == "-")?;
    let fstype = tokens.get(sep_pos + 1)?.to_string();
    let mountpoint = unescape_mountinfo(mountpoint_raw);

    Some(MountPoint {
        path: PathBuf::from(mountpoint),
        fstype,
    })
}

/// Unescape octal sequences in mountinfo paths (`\NNN` → byte).
///
/// The kernel encodes spaces and other special characters in paths as octal
/// escape sequences (e.g. `\040` for a space).
///
/// # Arguments
///
/// * `s` - The escaped string path from the kernel.
///
/// # Returns
///
/// Returns the decoded string.
fn unescape_mountinfo(s: &str) -> String {
    let mut out = Vec::with_capacity(s.len());
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'\\' && i + 3 < bytes.len() {
            let oct: Option<u8> = (|| {
                let a = (bytes[i + 1] as char).to_digit(8)? as u8;
                let b = (bytes[i + 2] as char).to_digit(8)? as u8;
                let c = (bytes[i + 3] as char).to_digit(8)? as u8;
                Some((a << 6) | (b << 3) | c)
            })();
            if let Some(byte) = oct {
                out.push(byte);
                i += 4;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8(out.clone()).unwrap_or_else(|_| String::from_utf8_lossy(&out).into_owned())
}

/// Call `umount2(path, MNT_DETACH)` directly via libc.
///
/// # Arguments
///
/// * `path` - The target path to unmount.
///
/// # Returns
///
/// Returns a standard I/O result.
fn try_umount2_detach(path: &Path) -> io::Result<()> {
    let cpath = CString::new(path.as_os_str().as_bytes())
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains null byte"))?;
    let ret = unsafe { libc::umount2(cpath.as_ptr(), MNT_DETACH) };
    if ret == 0 {
        Ok(())
    } else {
        Err(io::Error::last_os_error())
    }
}

/// Returns `true` if `e` indicates insufficient privileges.
///
/// # Arguments
///
/// * `e` - The error to inspect.
///
/// # Returns
///
/// Returns `true` if it is a permission-related error.
fn is_permission_error(e: &io::Error) -> bool {
    matches!(e.kind(), io::ErrorKind::PermissionDenied)
        || e.raw_os_error() == Some(libc::EPERM as i32)
}

/// Returns `true` when the filesystem type is FUSE-based.
///
/// # Arguments
///
/// * `fstype` - The filesystem type name.
///
/// # Returns
///
/// Returns `true` if the type is recognized as FUSE.
fn is_fuse(fstype: &str) -> bool {
    fstype == "fuse" || fstype == "fuseblk" || fstype.starts_with("fuse.")
}

/// Try `fusermount3 -u -z <path>`, falling back to `fusermount -u -z`.
///
/// # Arguments
///
/// * `path` - The target path to unmount using FUSE utilities.
///
/// # Returns
///
/// Returns `Ok(())` on success or a specific [`FusermountError`].
fn try_fusermount(path: &Path) -> std::result::Result<(), FusermountError> {
    for binary in &["fusermount3", "fusermount"] {
        match Command::new(binary)
            .args(["-u", "-z", "--"])
            .arg(path)
            .output()
        {
            Ok(out) if out.status.success() => return Ok(()),
            Ok(out) => {
                let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
                let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
                let msg = if stderr.is_empty() { stdout } else { stderr };
                return Err(FusermountError::Failed(format!(
                    "{binary}: {msg} (exit {})",
                    out.status
                        .code()
                        .map_or_else(|| "signal".into(), |c| c.to_string())
                )));
            }
            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
            Err(e) => return Err(FusermountError::Failed(e.to_string())),
        }
    }
    Err(FusermountError::NotFound)
}

#[cfg(test)]
#[path = "unmount_tests.rs"]
mod tests;