1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DaemonOptions {
34 #[serde(default = "default_interval")]
36 pub interval_secs: u64,
37 #[serde(default = "default_max_errors")]
39 pub max_consecutive_errors: u32,
40 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TickResult {
70 pub mounted: Vec<String>,
72 pub total_volumes: usize,
74 pub total_mounted: usize,
76 pub errors: Vec<String>,
78}
79
80pub 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 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
146fn 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
196fn 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
205const LAUNCHAGENT_LABEL: &str = "com.kodephp.ntfs-mac";
208const LAUNCHAGENT_PLIST_NAME: &str = "com.kodephp.ntfs-mac.plist";
209
210pub 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
239pub 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
266pub 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
279fn 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
311fn 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}