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 marker comment with the
4//! creating process's PID, enabling safe ownership checks and orphan cleanup.
5
6use crate::config::ResolverConfig;
7use crate::error::{ResolverError, Result};
8use crate::util::is_process_alive;
9use std::path::{Path, PathBuf};
10
11/// Marker comment embedded in every managed resolver file.
12const MANAGED_BY_MARKER: &str = "# managed by arcbox";
13
14/// Default macOS resolver directory.
15const DEFAULT_RESOLVER_DIR: &str = "/etc/resolver";
16
17/// Manages `/etc/resolver/<domain>` files.
18///
19/// # Lifecycle
20///
21/// 1. [`register`](Self::register) writes a resolver file.
22/// 2. macOS picks it up immediately (no restart needed).
23/// 3. [`unregister`](Self::unregister) removes the file on shutdown.
24///
25/// # Crash recovery
26///
27/// If the process exits without calling [`unregister`](Self::unregister),
28/// the file persists. On next startup, call
29/// [`cleanup_orphaned`](Self::cleanup_orphaned) to remove files whose
30/// creating PID is no longer running.
31///
32/// # Permissions
33///
34/// `/etc/resolver/` requires root. The caller must handle elevation.
35///
36/// # Example
37///
38/// ```rust,ignore
39/// use macos_resolver::{FileResolver, ResolverConfig};
40///
41/// let resolver = FileResolver::new();
42/// resolver.register(&ResolverConfig::new("myapp.local", "127.0.0.1", 5553))?;
43/// // ...
44/// resolver.unregister("myapp.local")?;
45/// ```
46pub struct FileResolver {
47    resolver_dir: PathBuf,
48}
49
50impl FileResolver {
51    /// Creates a resolver targeting the default `/etc/resolver` directory.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            resolver_dir: PathBuf::from(DEFAULT_RESOLVER_DIR),
56        }
57    }
58
59    /// Creates a resolver targeting a custom directory (useful for testing).
60    #[must_use]
61    pub fn with_dir(resolver_dir: impl Into<PathBuf>) -> Self {
62        Self {
63            resolver_dir: resolver_dir.into(),
64        }
65    }
66
67    /// Returns the resolver directory path.
68    #[must_use]
69    pub fn resolver_dir(&self) -> &Path {
70        &self.resolver_dir
71    }
72
73    /// Writes `/etc/resolver/<domain>` with the given configuration.
74    ///
75    /// The file contains a marker with the current PID for orphan detection.
76    /// Calling this again for the same domain overwrites the previous file.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`ResolverError::Io`] if the directory cannot be created or
81    /// the file cannot be written.
82    pub fn register(&self, config: &ResolverConfig) -> Result<()> {
83        if !self.resolver_dir.exists() {
84            std::fs::create_dir_all(&self.resolver_dir)?;
85        }
86
87        let path = self.resolver_path(&config.domain);
88        std::fs::write(&path, generate_file_content(config))?;
89
90        tracing::info!(
91            domain = %config.domain,
92            port = config.port,
93            path = %path.display(),
94            "Registered macOS DNS resolver"
95        );
96        Ok(())
97    }
98
99    /// Removes `/etc/resolver/<domain>`.
100    ///
101    /// Only removes files that contain the ownership marker. Files created
102    /// by other tools are left untouched and a [`ResolverError::NotManaged`]
103    /// error is returned.
104    ///
105    /// If the file does not exist, this is a no-op.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`ResolverError::Io`] on I/O failure, or
110    /// [`ResolverError::NotManaged`] if the file belongs to another tool.
111    pub fn unregister(&self, domain: &str) -> Result<()> {
112        let path = self.resolver_path(domain);
113
114        if !path.exists() {
115            tracing::debug!(domain = %domain, "Resolver file does not exist, skipping");
116            return Ok(());
117        }
118
119        if !is_managed(&path) {
120            tracing::warn!(
121                domain = %domain,
122                path = %path.display(),
123                "Resolver file not managed by this crate, refusing to remove"
124            );
125            return Err(ResolverError::NotManaged {
126                domain: domain.to_string(),
127            });
128        }
129
130        std::fs::remove_file(&path)?;
131        tracing::info!(domain = %domain, "Unregistered macOS DNS resolver");
132        Ok(())
133    }
134
135    /// Lists all domains with a managed resolver file.
136    ///
137    /// Returns an empty vec if the directory does not exist.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`ResolverError::Io`] if the directory cannot be read.
142    pub fn list(&self) -> Result<Vec<String>> {
143        if !self.resolver_dir.exists() {
144            return Ok(Vec::new());
145        }
146
147        let mut domains = Vec::new();
148        for entry in std::fs::read_dir(&self.resolver_dir)? {
149            let path = entry?.path();
150            if path.is_file() && is_managed(&path) {
151                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
152                    domains.push(name.to_string());
153                }
154            }
155        }
156        Ok(domains)
157    }
158
159    /// Returns `true` if `domain` has a managed resolver file on disk.
160    #[must_use]
161    pub fn is_registered(&self, domain: &str) -> bool {
162        let path = self.resolver_path(domain);
163        path.exists() && is_managed(&path)
164    }
165
166    /// Removes resolver files whose creating PID is no longer running.
167    ///
168    /// Returns the number of files removed. Non-managed files and files
169    /// belonging to still-alive processes are left untouched.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`ResolverError::Io`] if the directory cannot be read.
174    pub fn cleanup_orphaned(&self) -> Result<usize> {
175        if !self.resolver_dir.exists() {
176            return Ok(0);
177        }
178
179        let mut removed = 0;
180        for entry in std::fs::read_dir(&self.resolver_dir)? {
181            let path = entry?.path();
182            if !path.is_file() || !is_managed(&path) {
183                continue;
184            }
185
186            if let Some(pid) = extract_pid(&path) {
187                if !is_process_alive(pid) {
188                    let domain = path
189                        .file_name()
190                        .and_then(|n| n.to_str())
191                        .unwrap_or("unknown");
192                    tracing::info!(
193                        domain = %domain,
194                        pid = pid,
195                        "Removing orphaned resolver file (process dead)"
196                    );
197                    match std::fs::remove_file(&path) {
198                        Ok(()) => removed += 1,
199                        Err(e) => tracing::warn!(
200                            domain = %domain,
201                            error = %e,
202                            "Failed to remove orphaned resolver file"
203                        ),
204                    }
205                }
206            }
207        }
208        Ok(removed)
209    }
210
211    fn resolver_path(&self, domain: &str) -> PathBuf {
212        self.resolver_dir.join(domain)
213    }
214}
215
216impl Default for FileResolver {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222// ---------------------------------------------------------------------------
223// File content helpers
224// ---------------------------------------------------------------------------
225
226/// Generates resolver file content.
227///
228/// ```text
229/// # managed by arcbox (pid=12345)
230/// nameserver 127.0.0.1
231/// port 5553
232/// search_order 1
233/// ```
234fn generate_file_content(config: &ResolverConfig) -> String {
235    let pid = std::process::id();
236    format!(
237        "{MANAGED_BY_MARKER} (pid={pid})\nnameserver {ns}\nport {port}\nsearch_order {order}\n",
238        ns = config.nameserver,
239        port = config.port,
240        order = config.search_order,
241    )
242}
243
244/// Checks whether a file contains the ownership marker.
245fn is_managed(path: &Path) -> bool {
246    std::fs::read_to_string(path).is_ok_and(|c| c.contains(MANAGED_BY_MARKER))
247}
248
249/// Extracts the PID from `# managed by arcbox (pid=<N>)`.
250fn extract_pid(path: &Path) -> Option<u32> {
251    let content = std::fs::read_to_string(path).ok()?;
252    for line in content.lines() {
253        if let Some(rest) = line.strip_prefix(MANAGED_BY_MARKER) {
254            let rest = rest.trim().strip_prefix("(pid=")?;
255            return rest.strip_suffix(')')?.parse().ok();
256        }
257    }
258    None
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn generate_content_includes_marker_and_pid() {
267        let config = ResolverConfig::arcbox_default(5553);
268        let content = generate_file_content(&config);
269
270        assert!(content.contains(MANAGED_BY_MARKER));
271        assert!(content.contains("nameserver 127.0.0.1"));
272        assert!(content.contains("port 5553"));
273        assert!(content.contains("search_order 1"));
274        assert!(content.contains(&format!("pid={}", std::process::id())));
275    }
276
277    #[test]
278    fn register_and_unregister() {
279        let dir = tempfile::tempdir().unwrap();
280        let resolver = FileResolver::with_dir(dir.path());
281        let config = ResolverConfig::arcbox_default(5553);
282
283        resolver.register(&config).unwrap();
284        assert!(dir.path().join("arcbox.local").exists());
285        assert!(resolver.is_registered("arcbox.local"));
286
287        let content = std::fs::read_to_string(dir.path().join("arcbox.local")).unwrap();
288        assert!(content.contains(MANAGED_BY_MARKER));
289        assert!(content.contains("nameserver 127.0.0.1"));
290
291        assert_eq!(resolver.list().unwrap(), vec!["arcbox.local"]);
292
293        resolver.unregister("arcbox.local").unwrap();
294        assert!(!dir.path().join("arcbox.local").exists());
295        assert!(!resolver.is_registered("arcbox.local"));
296    }
297
298    #[test]
299    fn unregister_nonexistent_is_noop() {
300        let dir = tempfile::tempdir().unwrap();
301        let resolver = FileResolver::with_dir(dir.path());
302        resolver.unregister("nonexistent.local").unwrap();
303    }
304
305    #[test]
306    fn unregister_refuses_unmanaged_file() {
307        let dir = tempfile::tempdir().unwrap();
308        let path = dir.path().join("other.local");
309        std::fs::write(&path, "nameserver 1.1.1.1\nport 53\n").unwrap();
310
311        let resolver = FileResolver::with_dir(dir.path());
312        assert!(resolver.unregister("other.local").is_err());
313        assert!(path.exists());
314    }
315
316    #[test]
317    fn extract_pid_parses_marker() {
318        let dir = tempfile::tempdir().unwrap();
319        let path = dir.path().join("test.local");
320        std::fs::write(
321            &path,
322            "# managed by arcbox (pid=42)\nnameserver 127.0.0.1\nport 5553\n",
323        )
324        .unwrap();
325        assert_eq!(extract_pid(&path), Some(42));
326    }
327
328    #[test]
329    fn cleanup_removes_dead_pid_files() {
330        let dir = tempfile::tempdir().unwrap();
331        let resolver = FileResolver::with_dir(dir.path());
332
333        let path = dir.path().join("orphan.local");
334        std::fs::write(
335            &path,
336            "# managed by arcbox (pid=999999999)\nnameserver 127.0.0.1\nport 5553\n",
337        )
338        .unwrap();
339
340        assert_eq!(resolver.cleanup_orphaned().unwrap(), 1);
341        assert!(!path.exists());
342    }
343
344    #[test]
345    fn cleanup_preserves_alive_pid_files() {
346        let dir = tempfile::tempdir().unwrap();
347        let resolver = FileResolver::with_dir(dir.path());
348
349        let pid = std::process::id();
350        let path = dir.path().join("alive.local");
351        std::fs::write(
352            &path,
353            format!("# managed by arcbox (pid={pid})\nnameserver 127.0.0.1\nport 5553\n"),
354        )
355        .unwrap();
356
357        assert_eq!(resolver.cleanup_orphaned().unwrap(), 0);
358        assert!(path.exists());
359    }
360
361    #[test]
362    fn list_empty_and_nonexistent() {
363        let dir = tempfile::tempdir().unwrap();
364        assert!(
365            FileResolver::with_dir(dir.path())
366                .list()
367                .unwrap()
368                .is_empty()
369        );
370        assert!(
371            FileResolver::with_dir("/nonexistent")
372                .list()
373                .unwrap()
374                .is_empty()
375        );
376    }
377
378    #[test]
379    fn multiple_domains() {
380        let dir = tempfile::tempdir().unwrap();
381        let resolver = FileResolver::with_dir(dir.path());
382
383        resolver
384            .register(&ResolverConfig::arcbox_default(5553))
385            .unwrap();
386        resolver
387            .register(
388                &ResolverConfig::new("docker.internal", "127.0.0.1", 5553).with_search_order(2),
389            )
390            .unwrap();
391
392        let mut domains = resolver.list().unwrap();
393        domains.sort();
394        assert_eq!(domains, vec!["arcbox.local", "docker.internal"]);
395    }
396
397    #[test]
398    fn register_overwrites() {
399        let dir = tempfile::tempdir().unwrap();
400        let resolver = FileResolver::with_dir(dir.path());
401
402        resolver
403            .register(&ResolverConfig::arcbox_default(5553))
404            .unwrap();
405        resolver
406            .register(&ResolverConfig::arcbox_default(6000))
407            .unwrap();
408
409        let content = std::fs::read_to_string(dir.path().join("arcbox.local")).unwrap();
410        assert!(content.contains("port 6000"));
411        assert!(!content.contains("port 5553"));
412    }
413}