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