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::runner::RunOptions;
36use crate::{Config, device, mount};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DaemonOptions {
41 #[serde(default = "default_interval")]
43 pub interval_secs: u64,
44 #[serde(default = "default_max_errors")]
46 pub max_consecutive_errors: u32,
47 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct TickResult {
77 pub mounted: Vec<String>,
79 pub total_volumes: usize,
81 pub total_mounted: usize,
83 pub errors: Vec<String>,
85}
86
87pub 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 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
153fn 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
203fn 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
212const LAUNCHAGENT_LABEL: &str = "com.kodephp.ntfs-mac";
215const LAUNCHAGENT_PLIST_NAME: &str = "com.kodephp.ntfs-mac.plist";
216
217#[derive(Debug, Clone)]
219pub struct LaunchAgentInstall {
220 pub plist_path: PathBuf,
222 pub loaded: bool,
225 pub load_error: Option<String>,
228}
229
230const LAUNCHCTL_TIMEOUT: Duration = Duration::from_secs(20);
236
237const 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
247fn 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
264fn 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 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
320fn 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
336pub 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
351pub 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 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 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
399pub 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
428pub 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
441fn 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
482fn 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 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 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}