Skip to main content

macos_resolver/
file_resolver.rs

1//! File-based `/etc/resolver/` management.
2//!
3//! Each file written by this module contains a caller-defined marker prefix
4//! (e.g. `# managed by myapp`) with an optional PID, enabling safe ownership
5//! checks and orphan cleanup.
6
7use crate::config::ResolverConfig;
8use crate::error::{ResolverError, Result};
9use crate::util::is_process_alive;
10use std::path::{Path, PathBuf};
11
12/// Default macOS resolver directory.
13const DEFAULT_RESOLVER_DIR: &str = "/etc/resolver";
14
15/// Manages `/etc/resolver/<domain>` files.
16///
17/// # Lifecycle
18///
19/// 1. [`register`](Self::register) writes a resolver file.
20/// 2. macOS picks it up immediately (no restart needed).
21/// 3. [`unregister`](Self::unregister) removes the file on shutdown.
22///
23/// # Crash recovery
24///
25/// If the process exits without calling [`unregister`](Self::unregister),
26/// the file persists. On next startup, call
27/// [`cleanup_orphaned`](Self::cleanup_orphaned) to remove files whose
28/// creating PID is no longer running.
29///
30/// # Permissions
31///
32/// `/etc/resolver/` requires root. The caller must handle elevation.
33///
34/// # Example
35///
36/// ```rust,ignore
37/// use macos_resolver::{FileResolver, ResolverConfig};
38///
39/// let resolver = FileResolver::new("myapp");
40/// resolver.register(&ResolverConfig::new("myapp.local", "127.0.0.1", 5553))?;
41/// // ...
42/// resolver.unregister("myapp.local")?;
43/// ```
44pub struct FileResolver {
45    resolver_dir: PathBuf,
46    /// Marker prefix, e.g. `"myapp"`.
47    marker: String,
48}
49
50impl FileResolver {
51    /// Creates a resolver targeting the default `/etc/resolver` directory.
52    ///
53    /// `prefix` is used for two purposes:
54    ///
55    /// 1. **Marker comment** — files are tagged with `# managed by <prefix>`.
56    /// 2. **Environment variable namespace** — `{PREFIX}_RESOLVER_DIR` overrides
57    ///    the default `/etc/resolver` directory (prefix is uppercased, `-` → `_`).
58    #[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    /// Creates a resolver with an exact marker string (written as-is).
70    ///
71    /// Use this when you need full control over the marker comment.
72    #[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    /// Overrides the resolver directory (useful for testing).
81    #[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    /// Returns the resolver directory path.
88    #[must_use]
89    pub fn resolver_dir(&self) -> &Path {
90        &self.resolver_dir
91    }
92
93    /// Returns the marker string used to identify managed files.
94    #[must_use]
95    pub fn marker(&self) -> &str {
96        &self.marker
97    }
98
99    /// Writes `/etc/resolver/<domain>` with the given configuration.
100    ///
101    /// The file contains a marker with the current PID for orphan detection.
102    /// Calling this again for the same domain overwrites the previous file.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`ResolverError::Io`] if the directory cannot be created or
107    /// the file cannot be written.
108    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    /// Writes `/etc/resolver/<domain>` as a permanent (static) entry.
134    ///
135    /// Unlike [`register`](Self::register), this does **not** embed a PID in
136    /// the marker comment. The file is therefore immune to
137    /// [`cleanup_orphaned`](Self::cleanup_orphaned) (which skips files without
138    /// a PID) and survives daemon restarts.
139    ///
140    /// Intended for one-time installation commands (e.g. `sudo myapp dns install`).
141    ///
142    /// # Errors
143    ///
144    /// Returns [`ResolverError::Io`] if the directory cannot be created or
145    /// the file cannot be written.
146    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    /// Removes `/etc/resolver/<domain>`.
171    ///
172    /// Only removes files that contain the ownership marker. Files created
173    /// by other tools are left untouched and a [`ResolverError::NotManaged`]
174    /// error is returned.
175    ///
176    /// If the file does not exist, this is a no-op.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`ResolverError::Io`] on I/O failure, or
181    /// [`ResolverError::NotManaged`] if the file belongs to another tool.
182    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    /// Lists all domains with a managed resolver file.
207    ///
208    /// Returns an empty vec if the directory does not exist.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`ResolverError::Io`] if the directory cannot be read.
213    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    /// Returns `true` if `domain` has a managed resolver file on disk.
231    #[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    /// Removes resolver files whose creating PID is no longer running.
238    ///
239    /// Returns the number of files removed. Non-managed files and files
240    /// belonging to still-alive processes are left untouched.
241    /// Permanent files (no PID) are also left untouched.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`ResolverError::Io`] if the directory cannot be read.
246    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    /// Checks whether a file contains this instance's marker.
288    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    /// Extracts the PID from `# managed by <app> (pid=<N>)`.
293    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/// Converts a prefix like `"my-app"` to an environment variable prefix `"MY_APP"`.
306///
307/// Uppercases and replaces `-` with `_`.
308#[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}