//! Manage rclone VFS FUSE mount lifecycle. use std::path::Path; use anyhow::{Context, Result}; use crate::config::{Config, ShareConfig}; use super::config::RCLONE_CONF_PATH; /// Build the full `rclone mount` command-line arguments for a single share. /// /// Each share gets its own rclone mount process with a dedicated RC port. pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> Vec { let mut args = Vec::new(); // Subcommand and source:dest args.push("mount".into()); args.push(format!("{}:{}", share.connection, share.remote_path)); args.push(share.mount_point.display().to_string()); // Point to our generated rclone.conf args.push("--config".into()); args.push(RCLONE_CONF_PATH.into()); // VFS cache mode — full enables read-through + write-back args.push("--vfs-cache-mode".into()); args.push("full".into()); // Write-back delay args.push("--vfs-write-back".into()); args.push(config.writeback.write_back.clone()); // Cache size limits args.push("--vfs-cache-max-size".into()); args.push(config.cache.max_size.clone()); args.push("--vfs-cache-max-age".into()); args.push(config.cache.max_age.clone()); // NOTE: --vfs-cache-min-free-space requires rclone 1.65+. // Ubuntu apt may ship older versions. We detect support at runtime. if rclone_supports_min_free_space() { args.push("--vfs-cache-min-free-space".into()); args.push(config.cache.min_free.clone()); } // Cache directory (SSD path) args.push("--cache-dir".into()); args.push(config.cache.dir.display().to_string()); // Directory listing cache TTL args.push("--dir-cache-time".into()); args.push(config.directory_cache.cache_time.clone()); // Read optimization args.push("--buffer-size".into()); args.push(config.read.buffer_size.clone()); args.push("--vfs-read-chunk-size".into()); args.push(config.read.chunk_size.clone()); args.push("--vfs-read-chunk-size-limit".into()); args.push(config.read.chunk_limit.clone()); args.push("--vfs-read-ahead".into()); args.push(config.read.read_ahead.clone()); // Multi-thread download: splits large files across N parallel SFTP streams args.push("--multi-thread-streams".into()); args.push(config.read.multi_thread_streams.to_string()); args.push("--multi-thread-cutoff".into()); args.push(config.read.multi_thread_cutoff.clone()); // Concurrent transfers for write-back args.push("--transfers".into()); args.push(config.writeback.transfers.to_string()); // SFTP connection resilience: increase retries for flaky tunnels (Tailscale/WireGuard) args.push("--retries".into()); args.push("10".into()); args.push("--low-level-retries".into()); args.push("20".into()); args.push("--retries-sleep".into()); args.push("1s".into()); args.push("--contimeout".into()); args.push("30s".into()); // Bandwidth limits (only add flag if at least one direction is limited) let bw = format_bwlimit(&config.bandwidth.limit_up, &config.bandwidth.limit_down); if bw != "0" { args.push("--bwlimit".into()); args.push(bw); } // Enable rclone RC API on per-share port args.push("--rc".into()); args.push("--rc-addr".into()); args.push(format!("127.0.0.1:{rc_port}")); // Allow non-root users to access the FUSE mount (requires user_allow_other in /etc/fuse.conf) args.push("--allow-other".into()); args } /// Format the `--bwlimit` value from separate up/down strings. /// /// rclone accepts `RATE` for symmetric or `UP:DOWN` for asymmetric limits. /// Returns `"0"` when both directions are unlimited. fn format_bwlimit(up: &str, down: &str) -> String { let up_zero = up == "0" || up.is_empty(); let down_zero = down == "0" || down.is_empty(); match (up_zero, down_zero) { (true, true) => "0".into(), _ => format!("{up}:{down}"), } } /// Check if rclone supports `--vfs-cache-min-free-space` (added in v1.65). /// /// Runs `rclone mount --help` and checks for the flag in the output. /// Returns false if rclone is not found or the flag is absent. fn rclone_supports_min_free_space() -> bool { std::process::Command::new("rclone") .args(["mount", "--help"]) .output() .map(|o| { let stdout = String::from_utf8_lossy(&o.stdout); stdout.contains("--vfs-cache-min-free-space") }) .unwrap_or(false) } /// Build the rclone mount command as a string (for systemd ExecStart). pub fn build_mount_command(config: &Config, share: &ShareConfig, rc_port: u16) -> String { let args = build_mount_args(config, share, rc_port); format!("/usr/bin/rclone {}", args.join(" ")) } /// Check if a FUSE mount is currently active at the given mount point. pub fn is_mounted(mount_point: &Path) -> Result { let mp_str = mount_point.display().to_string(); let content = std::fs::read_to_string("/proc/mounts") .with_context(|| "Failed to read /proc/mounts")?; for line in content.lines() { // /proc/mounts format: device mountpoint fstype options dump pass let mut fields = line.split_whitespace(); let _device = fields.next(); if let Some(mp) = fields.next() && mp == mp_str { return Ok(true); } } Ok(false) } #[cfg(test)] mod tests { use super::*; fn test_config() -> Config { toml::from_str( r#" [[connections]] name = "nas" nas_host = "10.0.0.1" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, ) .unwrap() } #[test] fn test_format_bwlimit_both_zero() { assert_eq!(format_bwlimit("0", "0"), "0"); } #[test] fn test_format_bwlimit_both_empty() { assert_eq!(format_bwlimit("", ""), "0"); } #[test] fn test_format_bwlimit_mixed_zero_empty() { assert_eq!(format_bwlimit("0", ""), "0"); assert_eq!(format_bwlimit("", "0"), "0"); } #[test] fn test_format_bwlimit_up_only() { assert_eq!(format_bwlimit("10M", "0"), "10M:0"); } #[test] fn test_format_bwlimit_down_only() { assert_eq!(format_bwlimit("0", "50M"), "0:50M"); } #[test] fn test_format_bwlimit_both_set() { assert_eq!(format_bwlimit("10M", "50M"), "10M:50M"); } #[test] fn test_build_mount_args_contains_essentials() { let config = test_config(); let share = &config.shares[0]; let args = build_mount_args(&config, share, 5572); assert_eq!(args[0], "mount"); assert_eq!(args[1], "nas:/photos"); assert_eq!(args[2], "/mnt/photos"); assert!(args.contains(&"--config".to_string())); assert!(args.contains(&RCLONE_CONF_PATH.to_string())); assert!(args.contains(&"--vfs-cache-mode".to_string())); assert!(args.contains(&"full".to_string())); assert!(args.contains(&"--vfs-write-back".to_string())); assert!(args.contains(&"5s".to_string())); assert!(args.contains(&"--vfs-cache-max-size".to_string())); assert!(args.contains(&"200G".to_string())); assert!(args.contains(&"--vfs-cache-max-age".to_string())); assert!(args.contains(&"720h".to_string())); assert!(args.contains(&"--cache-dir".to_string())); assert!(args.contains(&"/tmp/cache".to_string())); assert!(args.contains(&"--dir-cache-time".to_string())); assert!(args.contains(&"1h".to_string())); assert!(args.contains(&"--buffer-size".to_string())); assert!(args.contains(&"--multi-thread-streams".to_string())); assert!(args.contains(&"--multi-thread-cutoff".to_string())); assert!(args.contains(&"--transfers".to_string())); assert!(args.contains(&"4".to_string())); assert!(args.contains(&"--rc".to_string())); assert!(args.contains(&"--rc-addr".to_string())); assert!(args.contains(&"127.0.0.1:5572".to_string())); assert!(args.contains(&"--allow-other".to_string())); } #[test] fn test_build_mount_args_no_bwlimit_when_unlimited() { let config = test_config(); let share = &config.shares[0]; let args = build_mount_args(&config, share, 5572); assert!(!args.contains(&"--bwlimit".to_string())); } #[test] fn test_build_mount_args_with_bwlimit() { let mut config = test_config(); config.bandwidth.limit_up = "10M".into(); config.bandwidth.limit_down = "50M".into(); let share = &config.shares[0]; let args = build_mount_args(&config, share, 5572); assert!(args.contains(&"--bwlimit".to_string())); assert!(args.contains(&"10M:50M".to_string())); } #[test] fn test_build_mount_command_format() { let config = test_config(); let share = &config.shares[0]; let cmd = build_mount_command(&config, share, 5572); assert!(cmd.starts_with("/usr/bin/rclone mount")); assert!(cmd.contains("nas:/photos")); assert!(cmd.contains("/mnt/photos")); } #[test] fn test_build_mount_args_custom_config() { let mut config = test_config(); config.shares[0].remote_path = "/volume1/media".into(); config.shares[0].mount_point = std::path::PathBuf::from("/mnt/media"); config.cache.dir = std::path::PathBuf::from("/ssd/cache"); config.writeback.transfers = 16; let share = &config.shares[0]; let args = build_mount_args(&config, share, 5573); assert_eq!(args[1], "nas:/volume1/media"); assert_eq!(args[2], "/mnt/media"); assert!(args.contains(&"/ssd/cache".to_string())); assert!(args.contains(&"16".to_string())); assert!(args.contains(&"127.0.0.1:5573".to_string())); } #[test] fn test_build_mount_args_different_rc_ports() { let config = test_config(); let share = &config.shares[0]; let args0 = build_mount_args(&config, share, 5572); assert!(args0.contains(&"127.0.0.1:5572".to_string())); let args1 = build_mount_args(&config, share, 5573); assert!(args1.contains(&"127.0.0.1:5573".to_string())); } }