1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DaemonOptions {
37 #[serde(default = "default_interval")]
39 pub interval_secs: u64,
40 #[serde(default = "default_max_errors")]
42 pub max_consecutive_errors: u32,
43 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TickResult {
73 pub mounted: Vec<String>,
75 pub total_volumes: usize,
77 pub total_mounted: usize,
79 pub errors: Vec<String>,
81}
82
83pub 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 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
149fn 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
199fn 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
208const LAUNCHAGENT_LABEL: &str = "com.kodephp.ntfs-mac";
211const LAUNCHAGENT_PLIST_NAME: &str = "com.kodephp.ntfs-mac.plist";
212
213#[derive(Debug, Clone)]
215pub struct LaunchAgentInstall {
216 pub plist_path: PathBuf,
218 pub loaded: bool,
221 pub load_error: Option<String>,
224}
225
226fn 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
239fn 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 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
295fn 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
311pub 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
326pub 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 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 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
374pub 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
403pub 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
416fn 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
457fn 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 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 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}