macos_resolver/
file_resolver.rs1use crate::config::ResolverConfig;
7use crate::error::{ResolverError, Result};
8use crate::util::is_process_alive;
9use std::path::{Path, PathBuf};
10
11const MANAGED_BY_MARKER: &str = "# managed by arcbox";
13
14const DEFAULT_RESOLVER_DIR: &str = "/etc/resolver";
16
17pub struct FileResolver {
47 resolver_dir: PathBuf,
48}
49
50impl FileResolver {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 resolver_dir: PathBuf::from(DEFAULT_RESOLVER_DIR),
56 }
57 }
58
59 #[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 #[must_use]
69 pub fn resolver_dir(&self) -> &Path {
70 &self.resolver_dir
71 }
72
73 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 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 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 #[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 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
222fn 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
244fn is_managed(path: &Path) -> bool {
246 std::fs::read_to_string(path).is_ok_and(|c| c.contains(MANAGED_BY_MARKER))
247}
248
249fn 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}