macos_resolver/
file_resolver.rs1use crate::config::ResolverConfig;
8use crate::error::{ResolverError, Result};
9use crate::util::is_process_alive;
10use std::path::{Path, PathBuf};
11
12const DEFAULT_RESOLVER_DIR: &str = "/etc/resolver";
14
15pub struct FileResolver {
45 resolver_dir: PathBuf,
46 marker: String,
48}
49
50impl FileResolver {
51 #[must_use]
59 pub fn new(prefix: &str) -> Self {
60 let env_key = format!("{}_RESOLVER_DIR", to_env_prefix(prefix));
61 let resolver_dir = std::env::var(env_key)
62 .map_or_else(|_| PathBuf::from(DEFAULT_RESOLVER_DIR), PathBuf::from);
63 Self {
64 resolver_dir,
65 marker: format!("# managed by {prefix}"),
66 }
67 }
68
69 #[must_use]
73 pub fn with_marker(marker: impl Into<String>) -> Self {
74 Self {
75 resolver_dir: PathBuf::from(DEFAULT_RESOLVER_DIR),
76 marker: marker.into(),
77 }
78 }
79
80 #[must_use]
82 pub fn dir(mut self, resolver_dir: impl Into<PathBuf>) -> Self {
83 self.resolver_dir = resolver_dir.into();
84 self
85 }
86
87 #[must_use]
89 pub fn resolver_dir(&self) -> &Path {
90 &self.resolver_dir
91 }
92
93 #[must_use]
95 pub fn marker(&self) -> &str {
96 &self.marker
97 }
98
99 pub fn register(&self, config: &ResolverConfig) -> Result<()> {
109 if !self.resolver_dir.exists() {
110 std::fs::create_dir_all(&self.resolver_dir)?;
111 }
112
113 let path = self.resolver_path(&config.domain);
114 let pid = std::process::id();
115 let content = format!(
116 "{marker} (pid={pid})\nnameserver {ns}\nport {port}\nsearch_order {order}\n",
117 marker = self.marker,
118 ns = config.nameserver,
119 port = config.port,
120 order = config.search_order,
121 );
122 std::fs::write(&path, content)?;
123
124 tracing::info!(
125 domain = %config.domain,
126 port = config.port,
127 path = %path.display(),
128 "Registered macOS DNS resolver"
129 );
130 Ok(())
131 }
132
133 pub fn register_permanent(&self, config: &ResolverConfig) -> Result<()> {
147 if !self.resolver_dir.exists() {
148 std::fs::create_dir_all(&self.resolver_dir)?;
149 }
150
151 let path = self.resolver_path(&config.domain);
152 let content = format!(
153 "{marker}\nnameserver {ns}\nport {port}\nsearch_order {order}\n",
154 marker = self.marker,
155 ns = config.nameserver,
156 port = config.port,
157 order = config.search_order,
158 );
159 std::fs::write(&path, content)?;
160
161 tracing::info!(
162 domain = %config.domain,
163 port = config.port,
164 path = %path.display(),
165 "Registered permanent macOS DNS resolver"
166 );
167 Ok(())
168 }
169
170 pub fn unregister(&self, domain: &str) -> Result<()> {
183 let path = self.resolver_path(domain);
184
185 if !path.exists() {
186 tracing::debug!(domain = %domain, "Resolver file does not exist, skipping");
187 return Ok(());
188 }
189
190 if !self.is_managed(&path) {
191 tracing::warn!(
192 domain = %domain,
193 path = %path.display(),
194 "Resolver file not managed by this instance, refusing to remove"
195 );
196 return Err(ResolverError::NotManaged {
197 domain: domain.to_string(),
198 });
199 }
200
201 std::fs::remove_file(&path)?;
202 tracing::info!(domain = %domain, "Unregistered macOS DNS resolver");
203 Ok(())
204 }
205
206 pub fn list(&self) -> Result<Vec<String>> {
214 if !self.resolver_dir.exists() {
215 return Ok(Vec::new());
216 }
217
218 let mut domains = Vec::new();
219 for entry in std::fs::read_dir(&self.resolver_dir)? {
220 let path = entry?.path();
221 if path.is_file() && self.is_managed(&path) {
222 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
223 domains.push(name.to_string());
224 }
225 }
226 }
227 Ok(domains)
228 }
229
230 #[must_use]
232 pub fn is_registered(&self, domain: &str) -> bool {
233 let path = self.resolver_path(domain);
234 path.exists() && self.is_managed(&path)
235 }
236
237 pub fn cleanup_orphaned(&self) -> Result<usize> {
247 if !self.resolver_dir.exists() {
248 return Ok(0);
249 }
250
251 let mut removed = 0;
252 for entry in std::fs::read_dir(&self.resolver_dir)? {
253 let path = entry?.path();
254 if !path.is_file() || !self.is_managed(&path) {
255 continue;
256 }
257
258 if let Some(pid) = self.extract_pid(&path) {
259 if !is_process_alive(pid) {
260 let domain = path
261 .file_name()
262 .and_then(|n| n.to_str())
263 .unwrap_or("unknown");
264 tracing::info!(
265 domain = %domain,
266 pid = pid,
267 "Removing orphaned resolver file (process dead)"
268 );
269 match std::fs::remove_file(&path) {
270 Ok(()) => removed += 1,
271 Err(e) => tracing::warn!(
272 domain = %domain,
273 error = %e,
274 "Failed to remove orphaned resolver file"
275 ),
276 }
277 }
278 }
279 }
280 Ok(removed)
281 }
282
283 fn resolver_path(&self, domain: &str) -> PathBuf {
284 self.resolver_dir.join(domain)
285 }
286
287 fn is_managed(&self, path: &Path) -> bool {
289 std::fs::read_to_string(path).is_ok_and(|c| c.contains(&self.marker))
290 }
291
292 fn extract_pid(&self, path: &Path) -> Option<u32> {
294 let content = std::fs::read_to_string(path).ok()?;
295 for line in content.lines() {
296 if let Some(rest) = line.strip_prefix(self.marker.as_str()) {
297 let rest = rest.trim().strip_prefix("(pid=")?;
298 return rest.strip_suffix(')')?.parse().ok();
299 }
300 }
301 None
302 }
303}
304
305#[must_use]
309pub fn to_env_prefix(prefix: &str) -> String {
310 prefix.to_uppercase().replace('-', "_")
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn test_config() -> ResolverConfig {
318 ResolverConfig::new("test.local", "127.0.0.1", 5553)
319 }
320
321 #[test]
322 fn marker_is_derived_from_prefix() {
323 let resolver = FileResolver::new("myapp");
324 assert_eq!(resolver.marker(), "# managed by myapp");
325 }
326
327 #[test]
328 fn register_writes_file_with_pid() {
329 let dir = tempfile::tempdir().unwrap();
330 let resolver = FileResolver::new("testapp").dir(dir.path());
331 let config = test_config();
332
333 resolver.register(&config).unwrap();
334 let content = std::fs::read_to_string(dir.path().join("test.local")).unwrap();
335
336 assert!(content.contains("testapp"));
337 assert!(content.contains("nameserver 127.0.0.1"));
338 assert!(content.contains("port 5553"));
339 assert!(content.contains("search_order 1"));
340 assert!(content.contains(&format!("pid={}", std::process::id())));
341 }
342
343 #[test]
344 fn register_and_unregister() {
345 let dir = tempfile::tempdir().unwrap();
346 let resolver = FileResolver::new("testapp").dir(dir.path());
347 let config = test_config();
348
349 resolver.register(&config).unwrap();
350 assert!(dir.path().join("test.local").exists());
351 assert!(resolver.is_registered("test.local"));
352 assert_eq!(resolver.list().unwrap(), vec!["test.local"]);
353
354 resolver.unregister("test.local").unwrap();
355 assert!(!dir.path().join("test.local").exists());
356 assert!(!resolver.is_registered("test.local"));
357 }
358
359 #[test]
360 fn unregister_nonexistent_is_noop() {
361 let dir = tempfile::tempdir().unwrap();
362 let resolver = FileResolver::new("testapp").dir(dir.path());
363 resolver.unregister("nonexistent.local").unwrap();
364 }
365
366 #[test]
367 fn unregister_refuses_unmanaged_file() {
368 let dir = tempfile::tempdir().unwrap();
369 let path = dir.path().join("other.local");
370 std::fs::write(&path, "nameserver 1.1.1.1\nport 53\n").unwrap();
371
372 let resolver = FileResolver::new("testapp").dir(dir.path());
373 assert!(resolver.unregister("other.local").is_err());
374 assert!(path.exists());
375 }
376
377 #[test]
378 fn unregister_refuses_file_from_different_app() {
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("shared.local");
381 std::fs::write(
382 &path,
383 "# managed by otherapp\nnameserver 127.0.0.1\nport 53\n",
384 )
385 .unwrap();
386
387 let resolver = FileResolver::new("myapp").dir(dir.path());
388 assert!(resolver.unregister("shared.local").is_err());
389 assert!(path.exists());
390 }
391
392 #[test]
393 fn extract_pid_parses_marker() {
394 let dir = tempfile::tempdir().unwrap();
395 let resolver = FileResolver::new("testapp").dir(dir.path());
396 let path = dir.path().join("test.local");
397 std::fs::write(
398 &path,
399 "# managed by testapp (pid=42)\nnameserver 127.0.0.1\nport 5553\n",
400 )
401 .unwrap();
402 assert_eq!(resolver.extract_pid(&path), Some(42));
403 }
404
405 #[test]
406 fn cleanup_removes_dead_pid_files() {
407 let dir = tempfile::tempdir().unwrap();
408 let resolver = FileResolver::new("testapp").dir(dir.path());
409
410 let path = dir.path().join("orphan.local");
411 std::fs::write(
412 &path,
413 "# managed by testapp (pid=999999999)\nnameserver 127.0.0.1\nport 5553\n",
414 )
415 .unwrap();
416
417 assert_eq!(resolver.cleanup_orphaned().unwrap(), 1);
418 assert!(!path.exists());
419 }
420
421 #[test]
422 fn cleanup_preserves_alive_pid_files() {
423 let dir = tempfile::tempdir().unwrap();
424 let resolver = FileResolver::new("testapp").dir(dir.path());
425
426 let pid = std::process::id();
427 let path = dir.path().join("alive.local");
428 std::fs::write(
429 &path,
430 format!("# managed by testapp (pid={pid})\nnameserver 127.0.0.1\nport 5553\n"),
431 )
432 .unwrap();
433
434 assert_eq!(resolver.cleanup_orphaned().unwrap(), 0);
435 assert!(path.exists());
436 }
437
438 #[test]
439 fn list_empty_and_nonexistent() {
440 let dir = tempfile::tempdir().unwrap();
441 assert!(
442 FileResolver::new("testapp")
443 .dir(dir.path())
444 .list()
445 .unwrap()
446 .is_empty()
447 );
448 assert!(
449 FileResolver::new("testapp")
450 .dir("/nonexistent")
451 .list()
452 .unwrap()
453 .is_empty()
454 );
455 }
456
457 #[test]
458 fn multiple_domains() {
459 let dir = tempfile::tempdir().unwrap();
460 let resolver = FileResolver::new("testapp").dir(dir.path());
461
462 resolver.register(&test_config()).unwrap();
463 resolver
464 .register(
465 &ResolverConfig::new("docker.internal", "127.0.0.1", 5553).with_search_order(2),
466 )
467 .unwrap();
468
469 let mut domains = resolver.list().unwrap();
470 domains.sort();
471 assert_eq!(domains, vec!["docker.internal", "test.local"]);
472 }
473
474 #[test]
475 fn register_permanent_creates_file_without_pid() {
476 let dir = tempfile::tempdir().unwrap();
477 let resolver = FileResolver::new("testapp").dir(dir.path());
478 let config = test_config();
479
480 resolver.register_permanent(&config).unwrap();
481 assert!(dir.path().join("test.local").exists());
482 assert!(resolver.is_registered("test.local"));
483
484 let content = std::fs::read_to_string(dir.path().join("test.local")).unwrap();
485 assert!(content.contains("testapp"));
486 assert!(content.contains("nameserver 127.0.0.1"));
487 assert!(content.contains("port 5553"));
488 assert!(!content.contains("pid="));
489 }
490
491 #[test]
492 fn cleanup_skips_permanent_files() {
493 let dir = tempfile::tempdir().unwrap();
494 let resolver = FileResolver::new("testapp").dir(dir.path());
495 let config = test_config();
496
497 resolver.register_permanent(&config).unwrap();
498 assert_eq!(resolver.cleanup_orphaned().unwrap(), 0);
499 assert!(dir.path().join("test.local").exists());
500 }
501
502 #[test]
503 fn register_overwrites() {
504 let dir = tempfile::tempdir().unwrap();
505 let resolver = FileResolver::new("testapp").dir(dir.path());
506
507 resolver.register(&test_config()).unwrap();
508 resolver
509 .register(&ResolverConfig::new("test.local", "127.0.0.1", 6000))
510 .unwrap();
511
512 let content = std::fs::read_to_string(dir.path().join("test.local")).unwrap();
513 assert!(content.contains("port 6000"));
514 assert!(!content.contains("port 5553"));
515 }
516}