typed-fuse 0.8.3

High-level, safe Rust wrapper for building FUSE 3 filesystems
//! Convenience mount configuration and a blocking mount+run helper shared
//! by FUSE daemons: build a [`MountConfig`], turn it into libfuse
//! [`MountOption`]s, and hand a [`PathFilesystem`] off to [`mount_blocking`].
//!
//! Per-OS mount-option rendering (e.g. `volname=` being macOS-only) lives in
//! [`MountOption`] itself, so this module and its callers stay
//! `#[cfg(target_os)]`-free.

use crate::{
    Error, MountOption, PathFilesystem, PathNodeFs, Session, SessionConfig, ThreadPoolConfig,
    ThreadingMode,
};
use std::path::Path;

/// Mount settings for a `PathFilesystem`-based daemon.
pub struct MountConfig {
    /// The mount source shown in the mount table (`fsname=`).
    pub fs_name: String,
    /// Filesystem type suffix (`subtype=`): Linux and FreeBSD report the
    /// mount's type as `fuse.<subtype>` / `fusefs.<subtype>`.
    pub subtype: Option<String>,
    pub allow_other: bool,
    pub allow_root: bool,
    pub default_permissions: bool,
    pub read_only: bool,
    pub nonempty: bool,
    /// macOS Finder volume name.
    pub volname: Option<String>,
    /// Additional raw mount option words, without any `-o` prefix.
    pub extra_options: Vec<String>,
}

impl MountConfig {
    /// A config with sensible defaults for `fs_name` (`default_permissions`
    /// on, everything else off).
    pub fn new(fs_name: impl Into<String>) -> Self {
        Self {
            fs_name: fs_name.into(),
            subtype: None,
            allow_other: false,
            allow_root: false,
            default_permissions: true,
            read_only: false,
            nonempty: false,
            volname: None,
            extra_options: Vec::new(),
        }
    }

    /// Applies a batch of comma-separated `-o` option words (e.g. already
    /// split from a `-o allow_other,ro` argument) to this config.
    /// Recognized words set the corresponding field; anything else is
    /// forwarded verbatim as an extra mount option.
    ///
    /// `ro`/`rw` are handled symmetrically: they toggle `read_only` rather
    /// than passing through, so a later `rw` undoes an earlier `ro` the way
    /// libfuse's own option parser would.
    ///
    /// `fsname=`, `subtype=` and `volname=` replace the corresponding field
    /// rather than adding a second word, which libfuse would not reliably
    /// honor (macFUSE keeps the first `fsname=`). Their values are taken
    /// unescaped; [`build_mount_options`]' rendering escapes them.
    pub fn parse_option_words<'a>(&mut self, words: impl IntoIterator<Item = &'a str>) {
        for word in words {
            if let Some(name) = word.strip_prefix("fsname=") {
                self.fs_name = name.to_string();
                continue;
            }
            if let Some(name) = word.strip_prefix("subtype=") {
                self.subtype = Some(name.to_string());
                continue;
            }
            if let Some(name) = word.strip_prefix("volname=") {
                self.volname = Some(name.to_string());
                continue;
            }
            match word {
                "allow_other" => self.allow_other = true,
                "allow_root" => self.allow_root = true,
                "nonempty" => self.nonempty = true,
                "ro" => self.read_only = true,
                "rw" => self.read_only = false,
                "default_permissions" => self.default_permissions = true,
                other => self.extra_options.push(other.to_string()),
            }
        }
    }
}

/// Renders a [`MountConfig`] to the [`MountOption`] list `Session` expects.
pub fn build_mount_options(cfg: &MountConfig) -> Vec<MountOption> {
    let mut options = vec![MountOption::FsName(cfg.fs_name.clone())];
    if let Some(subtype) = &cfg.subtype {
        options.push(MountOption::Subtype(subtype.clone()));
    }
    // `uid=`/`gid=` are high-level libfuse mount options (handled by
    // `fuse_main`'s uid/gid translation layer); the low-level session API
    // used here doesn't recognize them and libfuse rejects the mount with
    // "unknown option(s)". They're also unnecessary: `getattr` already
    // reports the backing filesystem's real ownership.
    if cfg.allow_other {
        options.push(MountOption::AllowOther);
    }
    if cfg.allow_root {
        options.push(MountOption::AllowRoot);
    }
    if cfg.default_permissions {
        options.push(MountOption::DefaultPermissions);
    }
    if cfg.read_only {
        options.push(MountOption::ReadOnly);
    }
    if cfg.nonempty {
        options.push(MountOption::NonEmpty);
    }
    if let Some(volname) = &cfg.volname {
        options.push(MountOption::VolName(volname.clone()));
    }
    options.extend(cfg.extra_options.iter().cloned().map(MountOption::Custom));
    // Every render that yields None on this platform already logs itself at
    // debug level in MountOption::render; this counts the total so callers
    // can spot "my option vanished" cases in one place.
    let dropped = options
        .iter()
        .filter(|option| option.render().is_none())
        .count();
    if dropped > 0 {
        log::debug!("{dropped} mount option(s) dropped as unsupported on this platform");
    }
    options
}

/// Mounts `fs` synchronously and runs libfuse's single- or multi-threaded
/// request loop until unmounted or signalled.
///
/// Call this after any daemonization: forking after libfuse has initialized
/// process state is unsafe.
pub fn mount_blocking<FS>(
    fs: FS,
    mount_point: &Path,
    cfg: &MountConfig,
    single_thread: bool,
) -> Result<(), Error>
where
    FS: PathFilesystem + 'static,
{
    let adapter = PathNodeFs::new(fs);
    let session_config = SessionConfig {
        threading: if single_thread {
            ThreadingMode::SingleThreaded
        } else {
            ThreadingMode::MultiThreaded(ThreadPoolConfig::default())
        },
    };
    let options = build_mount_options(cfg);
    let mut session = Session::new_with_config(adapter, &options, session_config)?;
    session.mount(mount_point)?;
    session.run()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn preserves_mount_policy_and_passthrough_options() {
        let mut config = MountConfig {
            fs_name: "encfs-test".to_string(),
            subtype: Some("encfs".to_string()),
            allow_other: true,
            allow_root: true,
            default_permissions: true,
            read_only: true,
            nonempty: true,
            volname: Some("Encrypted Files".to_string()),
            extra_options: Vec::new(),
        };
        config.parse_option_words(["noatime"]);

        let options = build_mount_options(&config);
        assert!(options.contains(&MountOption::FsName("encfs-test".to_string())));
        assert!(options.contains(&MountOption::Subtype("encfs".to_string())));
        assert!(options.contains(&MountOption::AllowOther));
        assert!(options.contains(&MountOption::AllowRoot));
        assert!(options.contains(&MountOption::DefaultPermissions));
        assert!(options.contains(&MountOption::ReadOnly));
        assert!(options.contains(&MountOption::NonEmpty));
        assert!(options.contains(&MountOption::VolName("Encrypted Files".to_string())));
        assert!(options.contains(&MountOption::Custom("noatime".to_string())));
        assert!(!options.iter().any(|option| matches!(
            option,
            MountOption::Custom(value) if value.starts_with("uid=") || value.starts_with("gid=")
        )));
    }

    #[test]
    fn parse_option_words_recognizes_known_words_and_passes_through_others() {
        let mut config = MountConfig::new("fs");
        config.parse_option_words(["allow_other", "allow_root", "nonempty", "ro", "noatime"]);
        assert!(config.allow_other);
        assert!(config.allow_root);
        assert!(config.nonempty);
        assert!(config.read_only);
        assert_eq!(config.extra_options, vec!["noatime".to_string()]);
    }

    #[test]
    fn subtype_is_omitted_by_default() {
        let options = build_mount_options(&MountConfig::new("fs"));
        assert!(
            !options
                .iter()
                .any(|option| matches!(option, MountOption::Subtype(_)))
        );
    }

    #[test]
    fn parse_option_words_replaces_named_values() {
        let mut config = MountConfig::new("default");
        config.parse_option_words(["fsname=/data/a,b", "subtype=myfs", "volname=Vol"]);
        config.parse_option_words(["fsname=/data/c"]);
        assert_eq!(config.fs_name, "/data/c");
        assert_eq!(config.subtype.as_deref(), Some("myfs"));
        assert_eq!(config.volname.as_deref(), Some("Vol"));
        assert!(config.extra_options.is_empty());

        let options = build_mount_options(&config);
        let fs_names = options
            .iter()
            .filter(|option| matches!(option, MountOption::FsName(_)))
            .count();
        assert_eq!(fs_names, 1);
    }

    #[test]
    fn parse_option_words_rw_toggles_read_only_back_off() {
        let mut config = MountConfig::new("fs");
        config.parse_option_words(["ro", "rw"]);
        assert!(!config.read_only);
        config.parse_option_words(["ro"]);
        assert!(config.read_only);
        assert!(config.extra_options.is_empty());
    }
}