Skip to main content

ntfs_mac_core/
daemon.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 kodephp contributors
3
4//! Auto-mount daemon.
5//!
6//! Watches for newly inserted NTFS volumes and mounts them
7//! automatically — the core Mounty-like feature. Uses periodic
8//! polling of `diskutil list -plist` (portable, no fseventsd
9//! dependency). Can be installed as a macOS LaunchAgent for
10//! login-time auto-start.
11//!
12//! # Design
13//!
14//! - Polls every 3 seconds (configurable via `DaemonOptions`).
15//! - Compares current volumes against a known-state snapshot.
16//! - Only mounts volumes that are unmounted and newly appeared.
17//! - Logs to stderr (daemon-friendly; stdout stays clean).
18//! - Graceful shutdown on SIGINT / SIGTERM.
19//!
20//! # LaunchAgent
21//!
22//! Installs `~/Library/LaunchAgents/com.kodephp.ntfs-mac.plist`
23//! that runs `ntfs-mac daemon` at login.
24
25use std::collections::HashSet;
26use std::path::PathBuf;
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::error::{ConfigError, Error, Result};
32use crate::{Config, device, mount};
33
34/// Options for the daemon.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DaemonOptions {
37    /// Poll interval in seconds.
38    #[serde(default = "default_interval")]
39    pub interval_secs: u64,
40    /// Max consecutive poll errors before giving up.
41    #[serde(default = "default_max_errors")]
42    pub max_consecutive_errors: u32,
43    /// Auto-mount only external volumes.
44    #[serde(default = "default_true")]
45    pub external_only: bool,
46}
47
48impl Default for DaemonOptions {
49    fn default() -> Self {
50        Self {
51            interval_secs: default_interval(),
52            max_consecutive_errors: default_max_errors(),
53            external_only: default_true(),
54        }
55    }
56}
57
58fn default_interval() -> u64 {
59    3
60}
61
62fn default_max_errors() -> u32 {
63    10
64}
65
66fn default_true() -> bool {
67    true
68}
69
70/// Result of a single daemon tick.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TickResult {
73    /// Volumes that were mounted this tick.
74    pub mounted: Vec<String>,
75    /// Total volumes seen.
76    pub total_volumes: usize,
77    /// Total mounted (pre-existing + newly mounted).
78    pub total_mounted: usize,
79    /// Errors encountered.
80    pub errors: Vec<String>,
81}
82
83/// Run the daemon until interrupted.
84///
85/// This function blocks. Call from a dedicated thread or process.
86/// Installs SIGINT/SIGTERM handlers; returns `Ok(())` on graceful
87/// shutdown, `Err(Error::Cancelled)` if the signal was received.
88pub fn run(cfg: &Config, opts: &DaemonOptions) -> Result<()> {
89    crate::hardening::install_signal_handlers();
90
91    let mut known: HashSet<String> = HashSet::new();
92    let mut consecutive_errors: u32 = 0;
93    let mut last_tick = std::time::Instant::now();
94
95    log_info(&format!(
96        "ntfs-mac daemon started (interval={}s, external_only={})",
97        opts.interval_secs, opts.external_only
98    ));
99    if let Ok(volumes) = device::list_volumes() {
100        known = collect_unmounted_ids(&volumes);
101        log_info(&format!(
102            "initial state: {} volumes, {} unmounted",
103            volumes.len(),
104            known.len()
105        ));
106    }
107
108    // Poll interval is short (500ms) so SIGINT/SIGTERM is noticed
109    // quickly; the actual tick cadence is controlled by `last_tick`.
110    let poll_interval = Duration::from_millis(500);
111
112    loop {
113        if crate::hardening::should_exit() {
114            log_info("shutdown signal received, exiting");
115            return Err(Error::Cancelled);
116        }
117
118        std::thread::sleep(poll_interval);
119
120        if last_tick.elapsed() < Duration::from_secs(opts.interval_secs) {
121            continue;
122        }
123        last_tick = std::time::Instant::now();
124
125        match device::list_volumes() {
126            Ok(volumes) => {
127                consecutive_errors = 0;
128                let result = tick(&volumes, &mut known, cfg, opts);
129                if !result.mounted.is_empty() {
130                    log_info(&format!("mounted: {}", result.mounted.join(", ")));
131                }
132            }
133            Err(e) => {
134                consecutive_errors += 1;
135                log_warn(&format!("poll error ({}): {e}", consecutive_errors));
136                if consecutive_errors >= opts.max_consecutive_errors {
137                    return Err(Error::CommandFailed {
138                        cmd: "daemon".into(),
139                        status: -1,
140                        stderr: format!("too many consecutive errors: {consecutive_errors}"),
141                        io: None,
142                    });
143                }
144            }
145        }
146    }
147}
148
149/// Single poll tick: detect new volumes and mount them.
150fn tick(
151    volumes: &[crate::Volume],
152    known: &mut HashSet<String>,
153    cfg: &Config,
154    opts: &DaemonOptions,
155) -> TickResult {
156    let mut mounted = Vec::new();
157    let mut errors = Vec::new();
158
159    for vol in volumes {
160        if vol.mounted {
161            continue;
162        }
163        if opts.external_only && vol.location != "external" {
164            continue;
165        }
166        if known.contains(&vol.device_identifier) {
167            continue;
168        }
169
170        log_info(&format!(
171            "detected new volume: {} ({})",
172            vol.display_label(),
173            vol.size_pretty
174        ));
175        let result = mount::mount(vol, &mount::MountOptions::default(), cfg);
176        match result {
177            Ok(mp) => {
178                mounted.push(format!("{} -> {}", vol.display_label(), mp));
179                log_info(&format!("mounted at {mp}"));
180            }
181            Err(e) => {
182                errors.push(format!("{}: {e}", vol.display_label()));
183                log_warn(&format!("mount failed for {}: {e}", vol.display_label()));
184            }
185        }
186    }
187
188    *known = collect_unmounted_ids(volumes);
189
190    let total_mounted = volumes.iter().filter(|v| v.mounted).count() + mounted.len();
191    TickResult {
192        mounted,
193        total_volumes: volumes.len(),
194        total_mounted,
195        errors,
196    }
197}
198
199/// Collect identifiers of unmounted volumes.
200fn collect_unmounted_ids(volumes: &[crate::Volume]) -> HashSet<String> {
201    volumes
202        .iter()
203        .filter(|v| !v.mounted)
204        .map(|v| v.device_identifier.clone())
205        .collect()
206}
207
208// --- LaunchAgent ---
209
210const LAUNCHAGENT_LABEL: &str = "com.kodephp.ntfs-mac";
211const LAUNCHAGENT_PLIST_NAME: &str = "com.kodephp.ntfs-mac.plist";
212
213/// Outcome of installing the LaunchAgent.
214#[derive(Debug, Clone)]
215pub struct LaunchAgentInstall {
216    /// Path of the written plist.
217    pub plist_path: PathBuf,
218    /// Whether the agent was successfully loaded into the user's
219    /// launchd session (`launchctl bootstrap`/`load`).
220    pub loaded: bool,
221    /// Diagnostic when `loaded` is `false` (the plist is still
222    /// installed and will start at next login).
223    pub load_error: Option<String>,
224}
225
226/// Numeric UID of the current user, via `id -u` (std has no uid API).
227fn user_uid() -> Option<String> {
228    use crate::runner::{RunOptions, run};
229    let out = run("id", &["-u"], &RunOptions::default()).ok()?;
230    if out.success() {
231        let s = out.stdout.trim().to_string();
232        if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() {
233            return Some(s);
234        }
235    }
236    None
237}
238
239/// Load the agent into the current user's launchd session.
240///
241/// Prefers the modern `launchctl bootstrap gui/<uid> <plist>`; falls
242/// back to the legacy `launchctl load <plist>`.
243///
244/// The legacy `load` subcommand can print "Load failed" and still exit
245/// with status 0, so the exit code alone proves nothing — success is
246/// always verified with `launchctl print`.
247fn load_launchagent(plist_path: &std::path::Path) -> Result<()> {
248    use crate::runner::{RunOptions, run};
249    let plist = plist_path.to_string_lossy().to_string();
250    let mut failures: Vec<String> = Vec::new();
251
252    if let Some(uid) = user_uid() {
253        if let Ok(out) = run(
254            "launchctl",
255            &["bootstrap", &format!("gui/{uid}"), &plist],
256            &RunOptions::default(),
257        ) {
258            if out.success() && is_launchagent_loaded() {
259                return Ok(());
260            }
261            let msg = out.stderr.trim();
262            if !msg.is_empty() {
263                failures.push(format!("bootstrap: {msg}"));
264            }
265        }
266        // bootstrap may have failed because the job is already loaded;
267        // check before falling back to the legacy path.
268        if is_launchagent_loaded() {
269            return Ok(());
270        }
271    }
272
273    if let Ok(out) = run("launchctl", &["load", &plist], &RunOptions::default()) {
274        if is_launchagent_loaded() {
275            return Ok(());
276        }
277        let msg = out.stderr.trim();
278        if !msg.is_empty() {
279            failures.push(format!("load: {msg}"));
280        }
281    }
282
283    Err(Error::CommandFailed {
284        cmd: "launchctl".into(),
285        status: -1,
286        stderr: if failures.is_empty() {
287            "could not verify the agent in launchd (`launchctl print` failed)".into()
288        } else {
289            failures.join("; ")
290        },
291        io: None,
292    })
293}
294
295/// Best-effort unload. Never fails the caller: if the agent is not
296/// loaded there is nothing to do, and a failed bootout must not stop
297/// the plist removal.
298fn unload_launchagent(plist_path: &std::path::Path) {
299    use crate::runner::{RunOptions, run};
300    let plist = plist_path.to_string_lossy().to_string();
301    if let Some(uid) = user_uid() {
302        let _ = run(
303            "launchctl",
304            &["bootout", &format!("gui/{uid}/{LAUNCHAGENT_LABEL}")],
305            &RunOptions::default(),
306        );
307    }
308    let _ = run("launchctl", &["unload", &plist], &RunOptions::default());
309}
310
311/// Whether launchd currently has the agent loaded.
312pub fn is_launchagent_loaded() -> bool {
313    use crate::runner::{RunOptions, run};
314    if let Some(uid) = user_uid() {
315        if let Ok(out) = run(
316            "launchctl",
317            &["print", &format!("gui/{uid}/{LAUNCHAGENT_LABEL}")],
318            &RunOptions::default(),
319        ) {
320            return out.success();
321        }
322    }
323    false
324}
325
326/// Install the daemon as a macOS LaunchAgent and load it into the
327/// current launchd session so it starts immediately (not just at the
328/// next login).
329pub fn install_launchagent(binary_path: &std::path::Path) -> Result<LaunchAgentInstall> {
330    let home = dirs::home_dir().ok_or_else(|| {
331        Error::Config(ConfigError::Read {
332            path: PathBuf::from("~"),
333            reason: "cannot determine home directory".into(),
334        })
335    })?;
336    let agent_dir = home.join("Library").join("LaunchAgents");
337    std::fs::create_dir_all(&agent_dir).map_err(|e| {
338        Error::Config(ConfigError::Write {
339            path: agent_dir.clone(),
340            reason: format!("cannot create {}: {e}", agent_dir.display()),
341        })
342    })?;
343    // launchd opens the log files itself; the directory must exist.
344    let logs_dir = home.join("Library").join("Logs");
345    let _ = std::fs::create_dir_all(&logs_dir);
346
347    let plist_path = agent_dir.join(LAUNCHAGENT_PLIST_NAME);
348    let plist = generate_launchagent_plist(binary_path, &home);
349    std::fs::write(&plist_path, plist).map_err(|e| {
350        Error::Config(ConfigError::Write {
351            path: plist_path.clone(),
352            reason: format!("cannot write {}: {e}", plist_path.display()),
353        })
354    })?;
355
356    log_info(&format!("LaunchAgent installed: {}", plist_path.display()));
357
358    // Replace any previously loaded instance so reinstall is idempotent.
359    unload_launchagent(&plist_path);
360    match load_launchagent(&plist_path) {
361        Ok(()) => Ok(LaunchAgentInstall {
362            plist_path,
363            loaded: true,
364            load_error: None,
365        }),
366        Err(e) => Ok(LaunchAgentInstall {
367            plist_path,
368            loaded: false,
369            load_error: Some(e.to_string()),
370        }),
371    }
372}
373
374/// Uninstall the LaunchAgent: unload it first (stopping the daemon),
375/// then remove the plist.
376pub fn uninstall_launchagent() -> Result<()> {
377    let home = dirs::home_dir().ok_or_else(|| {
378        Error::Config(ConfigError::Read {
379            path: PathBuf::from("~"),
380            reason: "cannot determine home directory".into(),
381        })
382    })?;
383    let plist_path = home
384        .join("Library")
385        .join("LaunchAgents")
386        .join(LAUNCHAGENT_PLIST_NAME);
387
388    if plist_path.exists() {
389        unload_launchagent(&plist_path);
390        std::fs::remove_file(&plist_path).map_err(|e| {
391            Error::Config(ConfigError::Write {
392                path: plist_path.clone(),
393                reason: format!("cannot remove {}: {e}", plist_path.display()),
394            })
395        })?;
396        log_info(&format!("LaunchAgent removed: {}", plist_path.display()));
397    } else {
398        log_info("LaunchAgent not installed");
399    }
400    Ok(())
401}
402
403/// Check if the LaunchAgent is installed.
404pub fn is_launchagent_installed() -> bool {
405    let home = dirs::home_dir();
406    match home {
407        Some(h) => h
408            .join("Library")
409            .join("LaunchAgents")
410            .join(LAUNCHAGENT_PLIST_NAME)
411            .exists(),
412        None => false,
413    }
414}
415
416/// Generate the LaunchAgent plist content.
417///
418/// launchd does NOT expand `~` in `StandardOutPath`, `StandardErrorPath`
419/// or `WorkingDirectory` — the values must be absolute paths, expanded
420/// against the user's home at generation time.
421fn generate_launchagent_plist(binary_path: &std::path::Path, home: &std::path::Path) -> String {
422    let log_path = home.join("Library").join("Logs").join("ntfs-mac.out.log");
423    let err_log_path = home.join("Library").join("Logs").join("ntfs-mac.err.log");
424    format!(
425        r#"<?xml version="1.0" encoding="UTF-8"?>
426<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
427<plist version="1.0">
428<dict>
429    <key>Label</key>
430    <string>{label}</string>
431    <key>ProgramArguments</key>
432    <array>
433        <string>{binary}</string>
434        <string>daemon</string>
435    </array>
436    <key>RunAtLoad</key>
437    <true/>
438    <key>KeepAlive</key>
439    <false/>
440    <key>StandardOutPath</key>
441    <string>{out_log}</string>
442    <key>StandardErrorPath</key>
443    <string>{err_log}</string>
444    <key>WorkingDirectory</key>
445    <string>{home}</string>
446</dict>
447</plist>
448"#,
449        label = LAUNCHAGENT_LABEL,
450        binary = binary_path.display(),
451        out_log = log_path.display(),
452        err_log = err_log_path.display(),
453        home = home.display(),
454    )
455}
456
457// --- Logging (uses tracing for consistent structured output) ---
458
459fn log_info(msg: &str) {
460    tracing::info!(target: "ntfs_mac_core::daemon", "{msg}");
461}
462
463fn log_warn(msg: &str) {
464    tracing::warn!(target: "ntfs_mac_core::daemon", "{msg}");
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn daemon_options_defaults() {
473        let opts = DaemonOptions::default();
474        assert_eq!(opts.interval_secs, 3);
475        assert_eq!(opts.max_consecutive_errors, 10);
476        assert!(opts.external_only);
477    }
478
479    #[test]
480    fn tick_mounts_new_volume() {
481        let cfg = Config::default();
482        let opts = DaemonOptions {
483            external_only: false,
484            ..Default::default()
485        };
486        let volumes = vec![crate::Volume {
487            device_identifier: "disk2s2".into(),
488            volume_name: "TestVol".into(),
489            media_type: "com.microsoft.ntfs".into(),
490            uuid: None,
491            size_bytes: 1000000000,
492            mounted: false,
493            mount_point: None,
494            parent_disk: Some("disk2".into()),
495            size_pretty: "0.9 GiB".into(),
496            location: "external".into(),
497            contents: Some("GUID_partition_scheme".into()),
498        }];
499        let mut known: HashSet<String> = HashSet::new();
500        let result = tick(&volumes, &mut known, &cfg, &opts);
501        assert_eq!(result.total_volumes, 1);
502        assert!(!result.errors.is_empty() || !result.mounted.is_empty());
503    }
504
505    #[test]
506    fn tick_skips_already_mounted() {
507        let cfg = Config::default();
508        let opts = DaemonOptions {
509            external_only: false,
510            ..Default::default()
511        };
512        let volumes = vec![crate::Volume {
513            device_identifier: "disk2s2".into(),
514            volume_name: "Mounted".into(),
515            media_type: "com.microsoft.ntfs".into(),
516            uuid: None,
517            size_bytes: 0,
518            mounted: true,
519            mount_point: Some("/Volumes/Mounted".into()),
520            parent_disk: Some("disk2".into()),
521            size_pretty: "0 B".into(),
522            location: "external".into(),
523            contents: None,
524        }];
525        let mut known: HashSet<String> = HashSet::new();
526        let result = tick(&volumes, &mut known, &cfg, &opts);
527        assert!(result.mounted.is_empty());
528        assert!(result.errors.is_empty());
529        assert_eq!(result.total_mounted, 1);
530    }
531
532    #[test]
533    fn tick_skips_internal_when_external_only() {
534        let cfg = Config::default();
535        let opts = DaemonOptions {
536            external_only: true,
537            ..Default::default()
538        };
539        let volumes = vec![crate::Volume {
540            device_identifier: "disk0s2".into(),
541            volume_name: "Internal".into(),
542            media_type: "com.microsoft.ntfs".into(),
543            uuid: None,
544            size_bytes: 0,
545            mounted: false,
546            mount_point: None,
547            parent_disk: Some("disk0".into()),
548            size_pretty: "0 B".into(),
549            location: "internal".into(),
550            contents: None,
551        }];
552        let mut known: HashSet<String> = HashSet::new();
553        let result = tick(&volumes, &mut known, &cfg, &opts);
554        assert!(result.mounted.is_empty());
555        assert!(result.errors.is_empty());
556    }
557
558    #[test]
559    fn launchagent_plist_generation() {
560        let home = std::path::Path::new("/Users/tester");
561        let plist =
562            generate_launchagent_plist(std::path::Path::new("/usr/local/bin/ntfs-mac"), home);
563        assert!(plist.contains("com.kodephp.ntfs-mac"));
564        assert!(plist.contains("/usr/local/bin/ntfs-mac"));
565        assert!(plist.contains("RunAtLoad"));
566        // launchd does not expand `~`: all paths must be absolute.
567        assert!(
568            !plist.contains(">~<"),
569            "plist must not contain literal ~ paths"
570        );
571        assert!(plist.contains("/Users/tester/Library/Logs/ntfs-mac.out.log"));
572        assert!(plist.contains("/Users/tester/Library/Logs/ntfs-mac.err.log"));
573        assert!(plist.contains("<string>/Users/tester</string>"));
574    }
575
576    #[test]
577    fn user_uid_is_numeric() {
578        // Not a hard requirement in exotic sandboxes, but on any real
579        // macOS box `id -u` works and returns digits.
580        if let Some(uid) = user_uid() {
581            assert!(!uid.is_empty());
582            assert!(uid.chars().all(|c| c.is_ascii_digit()));
583        }
584    }
585
586    #[test]
587    fn test_collect_unmounted_ids() {
588        let volumes = vec![
589            crate::Volume {
590                device_identifier: "disk2s2".into(),
591                volume_name: "A".into(),
592                media_type: "com.microsoft.ntfs".into(),
593                uuid: None,
594                size_bytes: 0,
595                mounted: false,
596                mount_point: None,
597                parent_disk: None,
598                size_pretty: "0 B".into(),
599                location: "external".into(),
600                contents: None,
601            },
602            crate::Volume {
603                device_identifier: "disk3s2".into(),
604                volume_name: "B".into(),
605                media_type: "com.microsoft.ntfs".into(),
606                uuid: None,
607                size_bytes: 0,
608                mounted: true,
609                mount_point: Some("/Volumes/B".into()),
610                parent_disk: None,
611                size_pretty: "0 B".into(),
612                location: "external".into(),
613                contents: None,
614            },
615        ];
616        let ids = collect_unmounted_ids(&volumes);
617        assert!(ids.contains("disk2s2"));
618        assert!(!ids.contains("disk3s2"));
619        assert_eq!(ids.len(), 1);
620    }
621}