Skip to main content

ntfs_mac_core/
daemon.rs

1//! Auto-mount daemon.
2//!
3//! Watches for newly inserted NTFS volumes and mounts them
4//! automatically — the core Mounty-like feature. Uses periodic
5//! polling of `diskutil list -plist` (portable, no fseventsd
6//! dependency). Can be installed as a macOS LaunchAgent for
7//! login-time auto-start.
8//!
9//! # Design
10//!
11//! - Polls every 3 seconds (configurable via `DaemonOptions`).
12//! - Compares current volumes against a known-state snapshot.
13//! - Only mounts volumes that are unmounted and newly appeared.
14//! - Logs to stderr (daemon-friendly; stdout stays clean).
15//! - Graceful shutdown on SIGINT / SIGTERM.
16//!
17//! # LaunchAgent
18//!
19//! Installs `~/Library/LaunchAgents/com.kodephp.ntfs-mac.plist`
20//! that runs `ntfs-mac daemon` at login.
21
22use std::collections::HashSet;
23use std::path::PathBuf;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27
28use crate::error::{ConfigError, Error, Result};
29use crate::{Config, device, mount};
30
31/// Options for the daemon.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DaemonOptions {
34    /// Poll interval in seconds.
35    #[serde(default = "default_interval")]
36    pub interval_secs: u64,
37    /// Max consecutive poll errors before giving up.
38    #[serde(default = "default_max_errors")]
39    pub max_consecutive_errors: u32,
40    /// Auto-mount only external volumes.
41    #[serde(default = "default_true")]
42    pub external_only: bool,
43}
44
45impl Default for DaemonOptions {
46    fn default() -> Self {
47        Self {
48            interval_secs: default_interval(),
49            max_consecutive_errors: default_max_errors(),
50            external_only: default_true(),
51        }
52    }
53}
54
55fn default_interval() -> u64 {
56    3
57}
58
59fn default_max_errors() -> u32 {
60    10
61}
62
63fn default_true() -> bool {
64    true
65}
66
67/// Result of a single daemon tick.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TickResult {
70    /// Volumes that were mounted this tick.
71    pub mounted: Vec<String>,
72    /// Total volumes seen.
73    pub total_volumes: usize,
74    /// Total mounted (pre-existing + newly mounted).
75    pub total_mounted: usize,
76    /// Errors encountered.
77    pub errors: Vec<String>,
78}
79
80/// Run the daemon until interrupted.
81///
82/// This function blocks. Call from a dedicated thread or process.
83/// Installs SIGINT/SIGTERM handlers; returns `Ok(())` on graceful
84/// shutdown, `Err(Error::Cancelled)` if the signal was received.
85pub fn run(cfg: &Config, opts: &DaemonOptions) -> Result<()> {
86    crate::hardening::install_signal_handlers();
87
88    let mut known: HashSet<String> = HashSet::new();
89    let mut consecutive_errors: u32 = 0;
90    let mut last_tick = std::time::Instant::now();
91
92    log_info(&format!(
93        "ntfs-mac daemon started (interval={}s, external_only={})",
94        opts.interval_secs, opts.external_only
95    ));
96    if let Ok(volumes) = device::list_volumes() {
97        known = collect_unmounted_ids(&volumes);
98        log_info(&format!(
99            "initial state: {} volumes, {} unmounted",
100            volumes.len(),
101            known.len()
102        ));
103    }
104
105    // Poll interval is short (500ms) so SIGINT/SIGTERM is noticed
106    // quickly; the actual tick cadence is controlled by `last_tick`.
107    let poll_interval = Duration::from_millis(500);
108
109    loop {
110        if crate::hardening::should_exit() {
111            log_info("shutdown signal received, exiting");
112            return Err(Error::Cancelled);
113        }
114
115        std::thread::sleep(poll_interval);
116
117        if last_tick.elapsed() < Duration::from_secs(opts.interval_secs) {
118            continue;
119        }
120        last_tick = std::time::Instant::now();
121
122        match device::list_volumes() {
123            Ok(volumes) => {
124                consecutive_errors = 0;
125                let result = tick(&volumes, &mut known, cfg, opts);
126                if !result.mounted.is_empty() {
127                    log_info(&format!("mounted: {}", result.mounted.join(", ")));
128                }
129            }
130            Err(e) => {
131                consecutive_errors += 1;
132                log_warn(&format!("poll error ({}): {e}", consecutive_errors));
133                if consecutive_errors >= opts.max_consecutive_errors {
134                    return Err(Error::CommandFailed {
135                        cmd: "daemon".into(),
136                        status: -1,
137                        stderr: format!("too many consecutive errors: {consecutive_errors}"),
138                        io: None,
139                    });
140                }
141            }
142        }
143    }
144}
145
146/// Single poll tick: detect new volumes and mount them.
147fn tick(
148    volumes: &[crate::Volume],
149    known: &mut HashSet<String>,
150    cfg: &Config,
151    opts: &DaemonOptions,
152) -> TickResult {
153    let mut mounted = Vec::new();
154    let mut errors = Vec::new();
155
156    for vol in volumes {
157        if vol.mounted {
158            continue;
159        }
160        if opts.external_only && vol.location != "external" {
161            continue;
162        }
163        if known.contains(&vol.device_identifier) {
164            continue;
165        }
166
167        log_info(&format!(
168            "detected new volume: {} ({})",
169            vol.display_label(),
170            vol.size_pretty
171        ));
172        let result = mount::mount(vol, &mount::MountOptions::default(), cfg);
173        match result {
174            Ok(mp) => {
175                mounted.push(format!("{} -> {}", vol.display_label(), mp));
176                log_info(&format!("mounted at {mp}"));
177            }
178            Err(e) => {
179                errors.push(format!("{}: {e}", vol.display_label()));
180                log_warn(&format!("mount failed for {}: {e}", vol.display_label()));
181            }
182        }
183    }
184
185    *known = collect_unmounted_ids(volumes);
186
187    let total_mounted = volumes.iter().filter(|v| v.mounted).count() + mounted.len();
188    TickResult {
189        mounted,
190        total_volumes: volumes.len(),
191        total_mounted,
192        errors,
193    }
194}
195
196/// Collect identifiers of unmounted volumes.
197fn collect_unmounted_ids(volumes: &[crate::Volume]) -> HashSet<String> {
198    volumes
199        .iter()
200        .filter(|v| !v.mounted)
201        .map(|v| v.device_identifier.clone())
202        .collect()
203}
204
205// --- LaunchAgent ---
206
207const LAUNCHAGENT_LABEL: &str = "com.kodephp.ntfs-mac";
208const LAUNCHAGENT_PLIST_NAME: &str = "com.kodephp.ntfs-mac.plist";
209
210/// Install the daemon as a macOS LaunchAgent.
211pub fn install_launchagent(binary_path: &std::path::Path) -> Result<PathBuf> {
212    let home = dirs::home_dir().ok_or_else(|| {
213        Error::Config(ConfigError::Read {
214            path: PathBuf::from("~"),
215            reason: "cannot determine home directory".into(),
216        })
217    })?;
218    let agent_dir = home.join("Library").join("LaunchAgents");
219    std::fs::create_dir_all(&agent_dir).map_err(|e| {
220        Error::Config(ConfigError::Write {
221            path: agent_dir.clone(),
222            reason: format!("cannot create {}: {e}", agent_dir.display()),
223        })
224    })?;
225
226    let plist_path = agent_dir.join(LAUNCHAGENT_PLIST_NAME);
227    let plist = generate_launchagent_plist(binary_path.to_str().unwrap_or("ntfs-mac"));
228    std::fs::write(&plist_path, plist).map_err(|e| {
229        Error::Config(ConfigError::Write {
230            path: plist_path.clone(),
231            reason: format!("cannot write {}: {e}", plist_path.display()),
232        })
233    })?;
234
235    log_info(&format!("LaunchAgent installed: {}", plist_path.display()));
236    Ok(plist_path)
237}
238
239/// Uninstall the LaunchAgent.
240pub fn uninstall_launchagent() -> Result<()> {
241    let home = dirs::home_dir().ok_or_else(|| {
242        Error::Config(ConfigError::Read {
243            path: PathBuf::from("~"),
244            reason: "cannot determine home directory".into(),
245        })
246    })?;
247    let plist_path = home
248        .join("Library")
249        .join("LaunchAgents")
250        .join(LAUNCHAGENT_PLIST_NAME);
251
252    if plist_path.exists() {
253        std::fs::remove_file(&plist_path).map_err(|e| {
254            Error::Config(ConfigError::Write {
255                path: plist_path.clone(),
256                reason: format!("cannot remove {}: {e}", plist_path.display()),
257            })
258        })?;
259        log_info(&format!("LaunchAgent removed: {}", plist_path.display()));
260    } else {
261        log_info("LaunchAgent not installed");
262    }
263    Ok(())
264}
265
266/// Check if the LaunchAgent is installed.
267pub fn is_launchagent_installed() -> bool {
268    let home = dirs::home_dir();
269    match home {
270        Some(h) => h
271            .join("Library")
272            .join("LaunchAgents")
273            .join(LAUNCHAGENT_PLIST_NAME)
274            .exists(),
275        None => false,
276    }
277}
278
279/// Generate the LaunchAgent plist content.
280fn generate_launchagent_plist(binary_path: &str) -> String {
281    format!(
282        r#"<?xml version="1.0" encoding="UTF-8"?>
283<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
284<plist version="1.0">
285<dict>
286    <key>Label</key>
287    <string>{label}</string>
288    <key>ProgramArguments</key>
289    <array>
290        <string>{binary}</string>
291        <string>daemon</string>
292    </array>
293    <key>RunAtLoad</key>
294    <true/>
295    <key>KeepAlive</key>
296    <false/>
297    <key>StandardOutPath</key>
298    <string>~/Library/Logs/ntfs-mac.out.log</string>
299    <key>StandardErrorPath</key>
300    <string>~/Library/Logs/ntfs-mac.err.log</string>
301    <key>WorkingDirectory</key>
302    <string>~</string>
303</dict>
304</plist>
305"#,
306        label = LAUNCHAGENT_LABEL,
307        binary = binary_path,
308    )
309}
310
311// --- Logging (uses tracing for consistent structured output) ---
312
313fn log_info(msg: &str) {
314    tracing::info!(target: "ntfs_mac_core::daemon", "{msg}");
315}
316
317fn log_warn(msg: &str) {
318    tracing::warn!(target: "ntfs_mac_core::daemon", "{msg}");
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn daemon_options_defaults() {
327        let opts = DaemonOptions::default();
328        assert_eq!(opts.interval_secs, 3);
329        assert_eq!(opts.max_consecutive_errors, 10);
330        assert!(opts.external_only);
331    }
332
333    #[test]
334    fn tick_mounts_new_volume() {
335        let cfg = Config::default();
336        let opts = DaemonOptions {
337            external_only: false,
338            ..Default::default()
339        };
340        let volumes = vec![crate::Volume {
341            device_identifier: "disk2s2".into(),
342            volume_name: "TestVol".into(),
343            media_type: "com.microsoft.ntfs".into(),
344            uuid: None,
345            size_bytes: 1000000000,
346            mounted: false,
347            mount_point: None,
348            parent_disk: Some("disk2".into()),
349            size_pretty: "0.9 GiB".into(),
350            location: "external".into(),
351            contents: Some("GUID_partition_scheme".into()),
352        }];
353        let mut known: HashSet<String> = HashSet::new();
354        let result = tick(&volumes, &mut known, &cfg, &opts);
355        assert_eq!(result.total_volumes, 1);
356        assert!(!result.errors.is_empty() || !result.mounted.is_empty());
357    }
358
359    #[test]
360    fn tick_skips_already_mounted() {
361        let cfg = Config::default();
362        let opts = DaemonOptions {
363            external_only: false,
364            ..Default::default()
365        };
366        let volumes = vec![crate::Volume {
367            device_identifier: "disk2s2".into(),
368            volume_name: "Mounted".into(),
369            media_type: "com.microsoft.ntfs".into(),
370            uuid: None,
371            size_bytes: 0,
372            mounted: true,
373            mount_point: Some("/Volumes/Mounted".into()),
374            parent_disk: Some("disk2".into()),
375            size_pretty: "0 B".into(),
376            location: "external".into(),
377            contents: None,
378        }];
379        let mut known: HashSet<String> = HashSet::new();
380        let result = tick(&volumes, &mut known, &cfg, &opts);
381        assert!(result.mounted.is_empty());
382        assert!(result.errors.is_empty());
383        assert_eq!(result.total_mounted, 1);
384    }
385
386    #[test]
387    fn tick_skips_internal_when_external_only() {
388        let cfg = Config::default();
389        let opts = DaemonOptions {
390            external_only: true,
391            ..Default::default()
392        };
393        let volumes = vec![crate::Volume {
394            device_identifier: "disk0s2".into(),
395            volume_name: "Internal".into(),
396            media_type: "com.microsoft.ntfs".into(),
397            uuid: None,
398            size_bytes: 0,
399            mounted: false,
400            mount_point: None,
401            parent_disk: Some("disk0".into()),
402            size_pretty: "0 B".into(),
403            location: "internal".into(),
404            contents: None,
405        }];
406        let mut known: HashSet<String> = HashSet::new();
407        let result = tick(&volumes, &mut known, &cfg, &opts);
408        assert!(result.mounted.is_empty());
409        assert!(result.errors.is_empty());
410    }
411
412    #[test]
413    fn launchagent_plist_generation() {
414        let plist = generate_launchagent_plist("/usr/local/bin/ntfs-mac");
415        assert!(plist.contains("com.kodephp.ntfs-mac"));
416        assert!(plist.contains("/usr/local/bin/ntfs-mac"));
417        assert!(plist.contains("RunAtLoad"));
418    }
419
420    #[test]
421    fn test_collect_unmounted_ids() {
422        let volumes = vec![
423            crate::Volume {
424                device_identifier: "disk2s2".into(),
425                volume_name: "A".into(),
426                media_type: "com.microsoft.ntfs".into(),
427                uuid: None,
428                size_bytes: 0,
429                mounted: false,
430                mount_point: None,
431                parent_disk: None,
432                size_pretty: "0 B".into(),
433                location: "external".into(),
434                contents: None,
435            },
436            crate::Volume {
437                device_identifier: "disk3s2".into(),
438                volume_name: "B".into(),
439                media_type: "com.microsoft.ntfs".into(),
440                uuid: None,
441                size_bytes: 0,
442                mounted: true,
443                mount_point: Some("/Volumes/B".into()),
444                parent_disk: None,
445                size_pretty: "0 B".into(),
446                location: "external".into(),
447                contents: None,
448            },
449        ];
450        let ids = collect_unmounted_ids(&volumes);
451        assert!(ids.contains("disk2s2"));
452        assert!(!ids.contains("disk3s2"));
453        assert_eq!(ids.len(), 1);
454    }
455}