Skip to main content

kftray_commons/utils/
hostsfile.rs

1use std::{
2    collections::HashSet,
3    fmt,
4    fs::OpenOptions,
5    io::{
6        self,
7        Write,
8    },
9    net::IpAddr,
10    path::{
11        Path,
12        PathBuf,
13    },
14};
15
16pub type Result<T, E = HostsFileError> = std::result::Result<T, E>;
17
18#[derive(Debug, Clone)]
19pub enum HostsFileError {
20    Io(String),
21    /// The caller may not write the file. Kept apart from other I/O errors
22    /// because a caller with a privileged writer at hand can route the same
23    /// edit through it.
24    PermissionDenied(String),
25    InvalidPath(String),
26    InvalidData(String),
27    UnsupportedPlatform,
28}
29
30impl fmt::Display for HostsFileError {
31    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
32        match self {
33            Self::Io(msg) => write!(f, "IO error: {}", msg),
34            Self::PermissionDenied(msg) => write!(f, "Permission denied: {}", msg),
35            Self::InvalidPath(msg) => write!(f, "Invalid path: {}", msg),
36            Self::InvalidData(msg) => write!(f, "Invalid data: {}", msg),
37            Self::UnsupportedPlatform => write!(f, "Unsupported platform"),
38        }
39    }
40}
41
42impl std::error::Error for HostsFileError {}
43
44impl From<io::Error> for HostsFileError {
45    fn from(err: io::Error) -> Self {
46        if err.kind() == io::ErrorKind::PermissionDenied {
47            Self::PermissionDenied(err.to_string())
48        } else {
49            Self::Io(err.to_string())
50        }
51    }
52}
53
54impl From<HostsFileError> for io::Error {
55    fn from(err: HostsFileError) -> Self {
56        match err {
57            HostsFileError::PermissionDenied(msg) => {
58                io::Error::new(io::ErrorKind::PermissionDenied, msg)
59            }
60            other => io::Error::other(other),
61        }
62    }
63}
64
65/// Trailing comment that records which configuration a line belongs to.
66const OWNER_MARKER: &str = " # kftray-id=";
67
68/// One line inside a managed section.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct SectionEntry {
71    pub ip: IpAddr,
72    pub hostname: String,
73    /// Configuration this entry was written for, when the writer recorded one.
74    /// Entries without an owner belong to another writer and are preserved.
75    pub owner: Option<String>,
76}
77
78/// Rejects an owner that could change the structure of the file.
79///
80/// Ids are plain tokens (`42`, `42-https-local`), so anything outside that
81/// alphabet is a mistake or an injection, never a real owner.
82pub fn validate_owner(owner: &str) -> Result<()> {
83    if owner.is_empty()
84        || !owner
85            .chars()
86            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
87    {
88        return Err(HostsFileError::InvalidData(format!(
89            "Invalid hosts entry owner {owner:?}"
90        )));
91    }
92    Ok(())
93}
94
95/// Rejects a hostname that would not stay on its own line.
96pub fn validate_hostname(hostname: &str) -> Result<()> {
97    if hostname.is_empty() || hostname.chars().any(|c| c.is_whitespace() || c == '#') {
98        return Err(HostsFileError::InvalidData(format!(
99            "Invalid hostname {hostname:?}"
100        )));
101    }
102    Ok(())
103}
104
105/// Runs `work` while holding an exclusive lock on the hosts file itself.
106///
107/// The file is the resource, so it is also the lock: there is no separate
108/// path to agree on between the unprivileged application and the privileged
109/// helper, nothing to create, nothing to chmod and nothing a symlink could
110/// redirect. It is opened read-only, which every writer can do.
111///
112/// On Unix the writer replaces the file by renaming a temporary over it. The
113/// lock lives on the inode, so a process that acquires it after such a rename
114/// holds a lock on the file that was just retired and no longer excludes
115/// anyone. That case is detected by comparing the locked descriptor with the
116/// path and starting over, which makes the lock on the current inode
117/// exclusive for as long as its holder is the one doing the rename.
118///
119/// Windows locks are mandatory and would block the holder's own write, so the
120/// lock covers one byte far past the end of the file instead of its content.
121fn with_hosts_lock<T>(path: &Path, recover: bool, work: impl FnOnce() -> Result<T>) -> Result<T> {
122    use crate::utils::config_dir::{
123        LockRegion,
124        unlock,
125    };
126
127    /// Releases the lock when dropped, including on unwind, so a panic in
128    /// `work` cannot leave the lock held until the file descriptor closes.
129    struct UnlockOnDrop<'a>(&'a std::fs::File);
130
131    impl Drop for UnlockOnDrop<'_> {
132        fn drop(&mut self) {
133            unlock(self.0, LockRegion::PendingByte);
134        }
135    }
136
137    match open_locked(path, recover)? {
138        Some(file) => {
139            let _unlock = UnlockOnDrop(&file);
140            work()
141        }
142        None => work(),
143    }
144}
145
146/// Opens the hosts file read-only and takes its lock.
147///
148/// On Unix the lock is retaken until the locked descriptor and the path name
149/// the same inode, which is what excludes a holder whose lock is on a file a
150/// rename just retired.
151/// Opens the hosts file read-only, creating it first when `create` is set
152/// and a custom path names a file that does not exist yet.
153///
154/// The system file is never created here: a missing one is an error the
155/// caller reports before reaching this. A custom path, as the legacy
156/// per-configuration cleanup and tests use, starts empty. `create(true)`
157/// tolerates another process winning the creation, so two writers starting
158/// on the same fresh path both end up locking the one file. A read passes
159/// `create: false` and promises never to create the file: a path that does
160/// not exist yields `Ok(None)` instead of one.
161fn open_for_lock(path: &Path, create: bool) -> Result<Option<std::fs::File>> {
162    match OpenOptions::new().read(true).open(path) {
163        Ok(file) => Ok(Some(file)),
164        Err(error) if error.kind() == io::ErrorKind::NotFound => {
165            if !create {
166                return Ok(None);
167            }
168            Ok(Some(
169                OpenOptions::new()
170                    .read(true)
171                    .write(true)
172                    .create(true)
173                    .truncate(false)
174                    .open(path)?,
175            ))
176        }
177        Err(error) => Err(error.into()),
178    }
179}
180
181/// Retries `attempt` until it reports success, sleeping `delay` between
182/// failures, up to `max_attempts` times.
183///
184/// A rename racing the lock can keep losing indefinitely; without a bound
185/// that turns into a hang instead of a reported error.
186#[cfg(unix)]
187fn retry_bounded<T>(
188    what: &str, max_attempts: u32, delay: std::time::Duration,
189    mut attempt: impl FnMut() -> Result<Option<T>>,
190) -> Result<T> {
191    for remaining in (0..max_attempts).rev() {
192        if let Some(value) = attempt()? {
193            return Ok(value);
194        }
195        if remaining > 0 {
196            std::thread::sleep(delay);
197        }
198    }
199    Err(HostsFileError::Io(format!(
200        "Timed out waiting for a stable lock on {what} after {max_attempts} attempts"
201    )))
202}
203
204/// Opens the hosts file's lock, or reports that there is nothing to lock.
205///
206/// `recover` doubles as "this is a write": only a write may create a
207/// missing file, so a read that finds nothing yields `Ok(None)` instead of
208/// creating one and locking it.
209#[cfg(unix)]
210fn open_locked(path: &Path, recover: bool) -> Result<Option<std::fs::File>> {
211    use std::os::unix::fs::MetadataExt;
212
213    use crate::utils::config_dir::{
214        LockRegion,
215        unlock,
216        wait_for_exclusive_lock,
217    };
218
219    let what = path.display().to_string();
220    retry_bounded(&what, 50, std::time::Duration::from_millis(20), || {
221        let Some(file) = open_for_lock(path, recover)? else {
222            return Ok(Some(None));
223        };
224        wait_for_exclusive_lock(&file, LockRegion::PendingByte, &what)
225            .map_err(HostsFileError::Io)?;
226
227        let locked = file.metadata()?;
228        match std::fs::metadata(path) {
229            Ok(current) if current.dev() == locked.dev() && current.ino() == locked.ino() => {
230                Ok(Some(Some(file)))
231            }
232            Ok(_) => {
233                unlock(&file, LockRegion::PendingByte);
234                Ok(None)
235            }
236            Err(error) if error.kind() == io::ErrorKind::NotFound && !recover => {
237                unlock(&file, LockRegion::PendingByte);
238                Ok(Some(None))
239            }
240            Err(error) => {
241                unlock(&file, LockRegion::PendingByte);
242                Err(error.into())
243            }
244        }
245    })
246}
247
248/// Where the content of an in-place rewrite is published before it is
249/// applied, and where a read looks for one that never completed.
250///
251/// The rewrite truncates the hosts file, so the new content is committed to
252/// this file first: a rewrite interrupted at any point is completed from it
253/// the next time the file is opened, rather than leaving the file truncated.
254/// It exists only while a rewrite is outstanding; an incomplete one is never
255/// visible because it is written to a temporary name and renamed into place.
256///
257/// A sibling of the hosts file itself is the only location: it is writable
258/// by exactly the principal that can write the hosts file (typically
259/// `drivers\etc\`, which requires the same elevation hosts editing does), so
260/// planting one needs no less privilege than editing hosts directly. A
261/// directory outside the hosts tree, such as `%ProgramData%`, is writable by
262/// far less privileged callers, which would let an unprivileged process
263/// stage a pending rewrite for an elevated recovery to apply over the real
264/// file.
265#[cfg(windows)]
266fn pending_path(path: &Path) -> PathBuf {
267    let mut pending = path.as_os_str().to_owned();
268    pending.push(".kftray-pending");
269    PathBuf::from(pending)
270}
271
272/// Confirms that `path`, when it lexically names the platform hosts file,
273/// still resolves to it once reparse points in its ancestry are followed.
274///
275/// Recovery below trusts `path` completely: it applies a pending rewrite to
276/// whatever it opens without re-deriving the location. A directory in the
277/// hosts file's ancestry replaced by a junction would not show up on
278/// `validate_hosts_path`'s check of the file itself, so it would otherwise
279/// survive to make recovery apply a planted pending copy to whatever the
280/// junction actually points at. A custom path, which never equals the
281/// platform path lexically, has no fixed location to compare against and is
282/// left alone.
283#[cfg(windows)]
284fn verify_platform_hosts_path(path: &Path) -> Result<()> {
285    let platform = get_platform_hosts_path()?;
286    if platform != path {
287        return Ok(());
288    }
289    if path.canonicalize()? != platform.canonicalize()? {
290        return Err(HostsFileError::InvalidPath(
291            "Hosts path does not resolve to the platform hosts file".to_string(),
292        ));
293    }
294    Ok(())
295}
296
297/// Opens the hosts file read-only and takes its lock, or reports that there
298/// is nothing to lock.
299///
300/// The writer rewrites the file in place on Windows, so the locked handle
301/// stays the current file and no identity check is needed there. A rewrite
302/// that did not complete is completed here, under the lock, when `recover`
303/// is set: the edit path repairs it before writing again, while a read
304/// leaves it in place, never creates the file, and reads the pending copy
305/// directly instead.
306#[cfg(windows)]
307fn open_locked(path: &Path, recover: bool) -> Result<Option<std::fs::File>> {
308    use crate::utils::config_dir::{
309        LockRegion,
310        wait_for_exclusive_lock,
311    };
312
313    if recover {
314        verify_platform_hosts_path(path)?;
315    }
316    let Some(file) = open_for_lock(path, recover)? else {
317        return Ok(None);
318    };
319    wait_for_exclusive_lock(&file, LockRegion::PendingByte, &path.display().to_string())
320        .map_err(HostsFileError::Io)?;
321    if recover {
322        let pending = pending_path(path);
323        validate_hosts_path(&pending)?;
324        if pending.exists() {
325            log::warn!(
326                "Completing an interrupted rewrite of the hosts file from {}",
327                pending.display()
328            );
329            std::fs::copy(&pending, path)?;
330            // Durable before the copy it was restored from goes: a power loss
331            // after the removal would otherwise leave the file partial with
332            // nothing left to complete it from.
333            OpenOptions::new().write(true).open(path)?.sync_all()?;
334            if let Err(error) = std::fs::remove_file(&pending) {
335                log::warn!(
336                    "Could not remove stale pending hosts rewrite at {}: {error}",
337                    pending.display()
338                );
339            }
340        }
341    }
342    Ok(Some(file))
343}
344
345/// One physical line, with the terminator it had on disk.
346///
347/// A line kftray did not touch is written back with exactly the terminator
348/// it was read with; only a line kftray itself formats picks the file's
349/// dominant terminator. Without this, rewriting one line of a Windows
350/// hosts file through `str::lines()` and `writeln!` silently turned every
351/// untouched CRLF line into LF.
352#[derive(Debug, Clone, PartialEq, Eq)]
353struct Line {
354    text: String,
355    crlf: bool,
356}
357
358impl Line {
359    fn new(text: impl Into<String>, crlf: bool) -> Self {
360        Self {
361            text: text.into(),
362            crlf,
363        }
364    }
365
366    fn is_empty(&self) -> bool {
367        self.text.is_empty()
368    }
369}
370
371/// The hosts file as raw lines, edited under one lock and written once.
372///
373/// Every operation preserves the lines it was not asked to touch exactly as
374/// they were: a comment, a blank line or an alias written by hand or by another
375/// writer is never reformatted or dropped, and a document that ends up
376/// unchanged is never written. That last property is what lets an
377/// unprivileged caller find out that nothing of its own is on disk without
378/// needing permission to write.
379pub struct HostsDocument {
380    lines: Vec<Line>,
381    original: Vec<Line>,
382    /// Whether the file, as read, ended in a newline after its last line.
383    ends_with_newline: bool,
384}
385
386/// One parsed line of a managed section.
387struct ParsedLine {
388    ip: IpAddr,
389    hostnames: Vec<String>,
390    owner: Option<String>,
391}
392
393impl HostsDocument {
394    fn load(path: &Path) -> Result<Self> {
395        let contents = Self::read_intended_content(path)?;
396        let (lines, ends_with_newline) = Self::split_content(&contents);
397        Ok(Self {
398            original: lines.clone(),
399            lines,
400            ends_with_newline,
401        })
402    }
403
404    /// Splits raw file content into lines, keeping each line's own CRLF/LF
405    /// terminator, and whether the content ended in a newline at all.
406    ///
407    /// `str::lines()` cannot be used here: it discards the terminator, which
408    /// is exactly what an untouched line must keep across a rewrite.
409    fn split_content(contents: &str) -> (Vec<Line>, bool) {
410        if contents.is_empty() {
411            return (Vec::new(), true);
412        }
413        let ends_with_newline = contents.ends_with('\n');
414        let mut raw: Vec<&str> = contents.split('\n').collect();
415        if ends_with_newline {
416            // The split on the final `\n` leaves an empty trailing element
417            // that is not a line.
418            raw.pop();
419        }
420        let lines = raw
421            .into_iter()
422            .map(|line| match line.strip_suffix('\r') {
423                Some(stripped) => Line::new(stripped, true),
424                None => Line::new(line, false),
425            })
426            .collect();
427        (lines, ends_with_newline)
428    }
429
430    #[cfg(not(windows))]
431    fn read_intended_content(path: &Path) -> Result<String> {
432        match std::fs::read_to_string(path) {
433            Ok(contents) => Ok(contents),
434            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
435            Err(error) => Err(error.into()),
436        }
437    }
438
439    /// The content a read should see: a pending rewrite for this exact path,
440    /// when one exists, or the file itself.
441    ///
442    /// A pending copy is the intended state of a write that was interrupted
443    /// before it completed; a read that ignored it would report an alias
444    /// gone that the interrupted write still committed to keep. It is only
445    /// ever read here, never applied: completing it is the edit path's job,
446    /// under its own lock, so a read never turns into a write and never
447    /// fails because one could not be made.
448    #[cfg(windows)]
449    fn read_intended_content(path: &Path) -> Result<String> {
450        let pending = pending_path(path);
451        match std::fs::read_to_string(&pending) {
452            Ok(contents) => return Ok(contents),
453            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
454            Err(error) => {
455                log::warn!(
456                    "Ignoring unreadable pending hosts rewrite at {}: {error}",
457                    pending.display()
458                );
459            }
460        }
461        match std::fs::read_to_string(path) {
462            Ok(contents) => Ok(contents),
463            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
464            Err(error) => Err(error.into()),
465        }
466    }
467
468    /// Whether anything differs from what was read.
469    pub fn is_dirty(&self) -> bool {
470        self.lines != self.original
471    }
472
473    /// The terminator a line kftray itself formats should use: whichever
474    /// one most of the file's lines already have. A file with no lines yet
475    /// (freshly created) follows the platform's own convention instead.
476    fn dominant_crlf(&self) -> bool {
477        if self.original.is_empty() {
478            return cfg!(windows);
479        }
480        let crlf_count = self.original.iter().filter(|line| line.crlf).count();
481        crlf_count * 2 > self.original.len()
482    }
483
484    fn begin_marker(tag: &str) -> String {
485        format!("# DO NOT EDIT {tag} BEGIN")
486    }
487
488    fn end_marker(tag: &str) -> String {
489        format!("# DO NOT EDIT {tag} END")
490    }
491
492    /// Indices of the begin and end markers of `tag`'s section.
493    ///
494    /// Half a section is an error, not an empty one: the aliases between a
495    /// begin marker and a missing end marker still resolve, and reporting
496    /// nothing would let a caller treat them as already removed.
497    /// The section's marker lines, or `None` when the file has no section for
498    /// the tag.
499    ///
500    /// A file with two sections for one tag is refused rather than read as
501    /// its first: an edit would touch only that one, and a verification that
502    /// followed would report an alias gone while the other section still
503    /// resolves it. Nothing here writes a second section, so one is a file
504    /// that was edited by hand and has to be repaired by hand.
505    fn bounds(&self, tag: &str) -> Result<Option<(usize, usize)>> {
506        let begin_marker = Self::begin_marker(tag);
507        let end_marker = Self::end_marker(tag);
508        let mut begins = self
509            .lines
510            .iter()
511            .enumerate()
512            .filter(|(_, line)| line.text.trim() == begin_marker)
513            .map(|(index, _)| index);
514        let mut ends = self
515            .lines
516            .iter()
517            .enumerate()
518            .filter(|(_, line)| line.text.trim() == end_marker)
519            .map(|(index, _)| index);
520        let (begin, end) = (begins.next(), ends.next());
521        if begins.next().is_some() || ends.next().is_some() {
522            return Err(HostsFileError::InvalidData(format!(
523                "Duplicate section markers for tag '{tag}'"
524            )));
525        }
526        match (begin, end) {
527            (None, None) => Ok(None),
528            (Some(begin), Some(end)) if begin < end => Ok(Some((begin, end))),
529            (Some(_), Some(_)) => Err(HostsFileError::InvalidData(format!(
530                "Reversed section markers for tag '{tag}'"
531            ))),
532            _ => Err(HostsFileError::InvalidData(format!(
533                "Incomplete section markers for tag '{tag}'"
534            ))),
535        }
536    }
537
538    /// Every begin/end marker pair for `tag`, in file order.
539    ///
540    /// Unlike `bounds`, more than one pair is not an error: this is what a
541    /// destructive removal uses to clean up a file a hand edit left with the
542    /// tag duplicated, which an in-place mutation could not safely target.
543    fn all_bounds(&self, tag: &str) -> Result<Vec<(usize, usize)>> {
544        let begin_marker = Self::begin_marker(tag);
545        let end_marker = Self::end_marker(tag);
546        // A stack pairs each END with the nearest BEGIN still open, so
547        // interleaved markers (BEGIN, BEGIN, END, END) nest rather than
548        // cross: the inner pair is folded into the outer one below instead
549        // of yielding two overlapping ranges a later drain could not apply
550        // safely.
551        let mut open: Vec<usize> = Vec::new();
552        let mut top_level: Vec<(usize, usize)> = Vec::new();
553        for (index, line) in self.lines.iter().enumerate() {
554            let trimmed = line.text.trim();
555            if trimmed == begin_marker {
556                open.push(index);
557            } else if trimmed == end_marker {
558                let Some(begin) = open.pop() else {
559                    return Err(HostsFileError::InvalidData(format!(
560                        "Incomplete section markers for tag '{tag}'"
561                    )));
562                };
563                if open.is_empty() {
564                    top_level.push((begin, index));
565                }
566            }
567        }
568        if !open.is_empty() {
569            return Err(HostsFileError::InvalidData(format!(
570                "Incomplete section markers for tag '{tag}'"
571            )));
572        }
573        Ok(top_level)
574    }
575
576    /// Folds every section for `tag` into one, in file order, when there is
577    /// more than one.
578    ///
579    /// `bounds` refuses a duplicated tag outright because an in-place edit
580    /// would not know which copy to touch. A destructive rewrite
581    /// (`reconcile_owners`, `retain`) is not so constrained: it can fall
582    /// back to merging every copy's body into the first one's place before
583    /// it goes on to do its own work through `bounds`, rather than failing
584    /// the whole operation over a file a hand edit left duplicated.
585    pub fn merge_duplicate_sections(&mut self, tag: &str) -> Result<()> {
586        let sections = self.all_bounds(tag)?;
587        if sections.len() <= 1 {
588            return Ok(());
589        }
590        let mut body: Vec<Line> = Vec::new();
591        for &(begin, end) in &sections {
592            body.extend(self.lines[begin + 1..end].iter().cloned());
593        }
594        for &(begin, end) in sections.iter().rev() {
595            self.lines.drain(begin..=end);
596            if begin > 0
597                && begin <= self.lines.len()
598                && self.lines[begin - 1].is_empty()
599                && self.lines.get(begin).is_none_or(Line::is_empty)
600            {
601                self.lines.remove(begin - 1);
602            }
603        }
604        self.set_body(tag, body)
605    }
606
607    /// Parses one line of a section. Comments and blank lines yield nothing.
608    fn parse_line(line: &str) -> Option<ParsedLine> {
609        // Everything from the first `#` is a comment. Ownership counts only
610        // when that comment is the marker itself, so a note that happens to
611        // mention the marker cannot make a foreign line look owned, and a real
612        // alias is never parsed as comment words.
613        let (fields, comment) = match line.split_once('#') {
614            Some((fields, comment)) => (fields, Some(comment)),
615            None => (line, None),
616        };
617        let owner = comment
618            .and_then(|comment| {
619                format!("#{comment}")
620                    .strip_prefix(OWNER_MARKER.trim_start())
621                    .map(ToOwned::to_owned)
622            })
623            .map(|owner| owner.trim().to_owned());
624        let mut fields = fields.split_whitespace();
625        let ip = fields.next()?.parse::<IpAddr>().ok()?;
626        let hostnames: Vec<String> = fields.map(ToOwned::to_owned).collect();
627        if hostnames.is_empty() {
628            return None;
629        }
630        Some(ParsedLine {
631            ip,
632            hostnames,
633            owner,
634        })
635    }
636
637    fn format_line(ip: IpAddr, hostnames: &[String], owner: Option<&str>) -> String {
638        match owner {
639            Some(owner) => format!("{ip} {}{OWNER_MARKER}{owner}", hostnames.join(" ")),
640            None => format!("{ip} {}", hostnames.join(" ")),
641        }
642    }
643
644    /// Every alias inside `tag`'s section, in file order.
645    pub fn section(&self, tag: &str) -> Result<Vec<SectionEntry>> {
646        let Some((begin, end)) = self.bounds(tag)? else {
647            return Ok(Vec::new());
648        };
649        Ok(self.entries_in(begin, end))
650    }
651
652    /// Every alias inside `tag`'s section(s), in file order, tolerating a
653    /// duplicated tag by reading and concatenating every section's body
654    /// instead of erroring the way `section` does through `bounds`.
655    ///
656    /// For a read-only caller that would otherwise have to fail outright on
657    /// a file a hand edit (or an older bug) left duplicated, before a later
658    /// write's own `merge_duplicate_sections` gets a chance to repair it.
659    pub fn section_merging_duplicates(&self, tag: &str) -> Result<Vec<SectionEntry>> {
660        Ok(self
661            .all_bounds(tag)?
662            .into_iter()
663            .flat_map(|(begin, end)| self.entries_in(begin, end))
664            .collect())
665    }
666
667    fn entries_in(&self, begin: usize, end: usize) -> Vec<SectionEntry> {
668        self.lines[begin + 1..end]
669            .iter()
670            .filter_map(|line| Self::parse_line(&line.text))
671            .flat_map(|parsed| {
672                parsed
673                    .hostnames
674                    .into_iter()
675                    .map(move |hostname| SectionEntry {
676                        ip: parsed.ip,
677                        hostname,
678                        owner: parsed.owner.clone(),
679                    })
680            })
681            .collect()
682    }
683
684    /// Replaces the body of `tag`'s section with `body`, creating the section
685    /// at the end of the file when it does not exist and removing it, markers
686    /// included, when `body` is empty.
687    fn set_body(&mut self, tag: &str, body: Vec<Line>) -> Result<()> {
688        let dominant = self.dominant_crlf();
689        match self.bounds(tag)? {
690            Some((begin, end)) => {
691                if body.is_empty() {
692                    self.lines.drain(begin..=end);
693                    // The blank line that separated the section from what came
694                    // before it goes with it, so repeated add-and-remove cycles
695                    // do not grow the file.
696                    if begin > 0
697                        && begin <= self.lines.len()
698                        && self.lines[begin - 1].is_empty()
699                        && self.lines.get(begin).is_none_or(Line::is_empty)
700                    {
701                        self.lines.remove(begin - 1);
702                    }
703                } else {
704                    self.lines.splice(begin + 1..end, body);
705                }
706            }
707            None => {
708                if body.is_empty() {
709                    return Ok(());
710                }
711                if self.lines.last().is_some_and(|last| !last.is_empty()) {
712                    self.lines.push(Line::new(String::new(), dominant));
713                }
714                self.lines
715                    .push(Line::new(Self::begin_marker(tag), dominant));
716                self.lines.extend(body);
717                self.lines.push(Line::new(Self::end_marker(tag), dominant));
718            }
719        }
720        Ok(())
721    }
722
723    /// Replaces `tag`'s section outright with one line per entry.
724    pub fn replace_section(&mut self, tag: &str, entries: &[SectionEntry]) -> Result<()> {
725        for entry in entries {
726            validate_hostname(&entry.hostname)?;
727            if let Some(owner) = &entry.owner {
728                validate_owner(owner)?;
729            }
730        }
731        let dominant = self.dominant_crlf();
732        let body = entries
733            .iter()
734            .map(|entry| {
735                Line::new(
736                    Self::format_line(
737                        entry.ip,
738                        std::slice::from_ref(&entry.hostname),
739                        entry.owner.as_deref(),
740                    ),
741                    dominant,
742                )
743            })
744            .collect();
745        self.set_body(tag, body)
746    }
747
748    /// Removes `tag`'s section whole, whoever wrote its lines.
749    pub fn clear_section(&mut self, tag: &str) -> Result<()> {
750        for (begin, end) in self.all_bounds(tag)?.into_iter().rev() {
751            self.lines.drain(begin..=end);
752            if begin > 0
753                && begin <= self.lines.len()
754                && self.lines[begin - 1].is_empty()
755                && self.lines.get(begin).is_none_or(Line::is_empty)
756            {
757                self.lines.remove(begin - 1);
758            }
759        }
760        Ok(())
761    }
762
763    /// Replaces the lines owned by `owners` with `entries` and leaves every
764    /// other line of the section as it is.
765    ///
766    /// Passing no entries removes those owners. Returns the subset of
767    /// `owners` that had lines before the change, so a caller can tell an id
768    /// it never held apart from one it just took off disk.
769    pub fn reconcile_owners(
770        &mut self, tag: &str, owners: &[&str], entries: &[SectionEntry],
771    ) -> Result<HashSet<String>> {
772        for owner in owners {
773            validate_owner(owner)?;
774        }
775        for entry in entries {
776            validate_hostname(&entry.hostname)?;
777            if let Some(owner) = &entry.owner {
778                validate_owner(owner)?;
779            }
780        }
781        self.merge_duplicate_sections(tag)?;
782        let mut present = HashSet::new();
783        let dominant = self.dominant_crlf();
784        let mut body: Vec<Line> = match self.bounds(tag)? {
785            Some((begin, end)) => self.lines[begin + 1..end]
786                .iter()
787                .filter(
788                    |line| match Self::parse_line(&line.text).and_then(|parsed| parsed.owner) {
789                        Some(owner) if owners.contains(&owner.as_str()) => {
790                            present.insert(owner);
791                            false
792                        }
793                        _ => true,
794                    },
795                )
796                .cloned()
797                .collect(),
798            None => Vec::new(),
799        };
800        body.extend(entries.iter().map(|entry| {
801            Line::new(
802                Self::format_line(
803                    entry.ip,
804                    std::slice::from_ref(&entry.hostname),
805                    entry.owner.as_deref(),
806                ),
807                dominant,
808            )
809        }));
810        self.set_body(tag, body)?;
811        Ok(present)
812    }
813
814    /// Keeps the aliases of `tag`'s section that `keep` accepts.
815    ///
816    /// A line none of whose aliases is rejected is preserved byte for byte;
817    /// one with some rejected is rewritten with the rest.
818    pub fn retain(&mut self, tag: &str, keep: impl Fn(&SectionEntry) -> bool) -> Result<()> {
819        self.merge_duplicate_sections(tag)?;
820        let Some((begin, end)) = self.bounds(tag)? else {
821            return Ok(());
822        };
823        let dominant = self.dominant_crlf();
824        let body: Vec<Line> = self.lines[begin + 1..end]
825            .iter()
826            .filter_map(|line| {
827                let Some(parsed) = Self::parse_line(&line.text) else {
828                    return Some(line.clone());
829                };
830                let kept: Vec<String> = parsed
831                    .hostnames
832                    .iter()
833                    .filter(|hostname| {
834                        keep(&SectionEntry {
835                            ip: parsed.ip,
836                            hostname: (*hostname).clone(),
837                            owner: parsed.owner.clone(),
838                        })
839                    })
840                    .cloned()
841                    .collect();
842                if kept.len() == parsed.hostnames.len() {
843                    Some(line.clone())
844                } else if kept.is_empty() {
845                    None
846                } else {
847                    Some(Line::new(
848                        Self::format_line(parsed.ip, &kept, parsed.owner.as_deref()),
849                        dominant,
850                    ))
851                }
852            })
853            .collect();
854        self.set_body(tag, body)
855    }
856
857    fn commit(&self, path: &Path) -> Result<bool> {
858        if !self.is_dirty() {
859            return Ok(false);
860        }
861        // The file's own trailing newline is kept only when the very last
862        // line on disk is still, byte for byte, the last line being
863        // written: anything appended or changed after it always ends in a
864        // newline, matching every line before it.
865        let omit_trailing_terminator =
866            !self.ends_with_newline && self.lines.last() == self.original.last();
867        let last_index = self.lines.len().saturating_sub(1);
868        let mut content = Vec::new();
869        for (index, line) in self.lines.iter().enumerate() {
870            content.extend_from_slice(line.text.as_bytes());
871            if index == last_index && omit_trailing_terminator {
872                continue;
873            }
874            content.extend_from_slice(if line.crlf { b"\r\n" } else { b"\n" });
875        }
876        AtomicFileWriter::new(path).write_content(&content)?;
877        Ok(true)
878    }
879}
880
881/// Edits the system hosts file under its lock and writes it back once, only
882/// if anything changed.
883pub fn edit_hosts<T>(edit: impl FnOnce(&mut HostsDocument) -> Result<T>) -> Result<T> {
884    edit_hosts_at(&get_default_hosts_path()?, edit)
885}
886
887/// [`edit_hosts`] against a specific file.
888pub fn edit_hosts_at<T>(
889    path: &Path, edit: impl FnOnce(&mut HostsDocument) -> Result<T>,
890) -> Result<T> {
891    validate_hosts_target_path(path)?;
892    with_hosts_lock(path, true, || {
893        let mut document = HostsDocument::load(path)?;
894        let outcome = edit(&mut document)?;
895        document.commit(path)?;
896        Ok(outcome)
897    })
898}
899
900/// Reads the system hosts file under its lock.
901///
902/// Locked like a write: the writer's fallback rewrites the file in place, and
903/// a read in the middle of that would see an empty file and report aliases
904/// gone that the completed write still holds.
905pub fn read_hosts<T>(read: impl FnOnce(&HostsDocument) -> Result<T>) -> Result<T> {
906    read_hosts_at(&get_default_hosts_path()?, read)
907}
908
909/// [`read_hosts`] against a specific file.
910pub fn read_hosts_at<T>(path: &Path, read: impl FnOnce(&HostsDocument) -> Result<T>) -> Result<T> {
911    validate_hosts_target_path(path)?;
912    // A file that does not exist holds nothing, and a read must not be the
913    // thing that creates it.
914    if !path.exists() {
915        return read(&HostsDocument {
916            lines: Vec::new(),
917            original: Vec::new(),
918            ends_with_newline: true,
919        });
920    }
921    with_hosts_lock(path, false, || read(&HostsDocument::load(path)?))
922}
923
924/// A set of aliases to write as one tagged section.
925pub struct HostsFile {
926    entries: Vec<SectionEntry>,
927    tag: String,
928}
929
930impl HostsFile {
931    pub fn new<S: Into<String>>(tag: S) -> Self {
932        Self {
933            entries: Vec::new(),
934            tag: tag.into(),
935        }
936    }
937
938    pub fn add_entry<S: ToString>(&mut self, ip: IpAddr, hostname: S) -> Result<&mut Self> {
939        let hostname = hostname.to_string();
940        validate_hostname(&hostname)?;
941        self.entries.push(SectionEntry {
942            ip,
943            hostname,
944            owner: None,
945        });
946        Ok(self)
947    }
948
949    pub fn add_entries<I, S>(&mut self, ip: IpAddr, hostnames: I) -> Result<&mut Self>
950    where
951        I: IntoIterator<Item = S>,
952        S: ToString,
953    {
954        for hostname in hostnames {
955            self.add_entry(ip, hostname)?;
956        }
957        Ok(self)
958    }
959
960    /// Adds an entry that records which configuration owns it.
961    ///
962    /// Entries written without an owner cannot be told apart, and a writer
963    /// that rebuilt a section from what it remembers would drop every line it
964    /// did not write itself. The owner goes into a trailing comment, which the
965    /// hosts file format ignores, so a writer can replace exactly its own
966    /// lines and leave every other line alone.
967    ///
968    /// Owners are restricted to a plain token. The value ends up inside the
969    /// file, and an owner carrying a line break or a `#` would otherwise turn
970    /// into an active mapping, or comment out the one it was meant to mark.
971    pub fn add_owned_entry<S: ToString>(
972        &mut self, ip: IpAddr, hostname: S, owner: &str,
973    ) -> Result<&mut Self> {
974        validate_owner(owner)?;
975        let hostname = hostname.to_string();
976        validate_hostname(&hostname)?;
977        self.entries.push(SectionEntry {
978            ip,
979            hostname,
980            owner: Some(owner.to_owned()),
981        });
982        Ok(self)
983    }
984
985    pub fn is_empty(&self) -> bool {
986        self.entries.is_empty()
987    }
988
989    pub fn entry_count(&self) -> usize {
990        self.entries.len()
991    }
992
993    /// Replaces this tag's section with the staged entries. No entries
994    /// removes the section. Returns whether the file changed.
995    pub fn write(&self) -> Result<bool> {
996        self.write_to(get_default_hosts_path()?)
997    }
998
999    pub fn write_to<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
1000        edit_hosts_at(path.as_ref(), |document| {
1001            document.replace_section(&self.tag, &self.entries)?;
1002            Ok(document.is_dirty())
1003        })
1004    }
1005
1006    /// Reads a section from a specific file.
1007    pub fn read_section_from<P: AsRef<Path>>(&self, path: P) -> Result<Vec<SectionEntry>> {
1008        read_hosts_at(path.as_ref(), |document| document.section(&self.tag))
1009    }
1010
1011    /// Replaces the lines owned by `owners` with the staged entries and leaves
1012    /// every other line alone, as one locked read-modify-write. See
1013    /// [`HostsDocument::reconcile_owners`].
1014    pub fn reconcile_owners_in<P: AsRef<Path>>(
1015        &self, path: P, owners: &[&str],
1016    ) -> Result<HashSet<String>> {
1017        for owner in owners {
1018            validate_owner(owner)?;
1019        }
1020        edit_hosts_at(path.as_ref(), |document| {
1021            document.reconcile_owners(&self.tag, owners, &self.entries)
1022        })
1023    }
1024
1025    /// Reads this tag's section, keeps the entries `keep` accepts, and writes
1026    /// the result back as one locked operation.
1027    pub fn retain_section_in<P: AsRef<Path>>(
1028        &self, path: P, keep: impl Fn(&SectionEntry) -> bool,
1029    ) -> Result<bool> {
1030        edit_hosts_at(path.as_ref(), |document| {
1031            document.retain(&self.tag, keep)?;
1032            Ok(document.is_dirty())
1033        })
1034    }
1035}
1036
1037struct AtomicFileWriter<'a> {
1038    target_path: &'a Path,
1039}
1040
1041impl<'a> AtomicFileWriter<'a> {
1042    fn new(path: &'a Path) -> Self {
1043        Self { target_path: path }
1044    }
1045
1046    #[cfg(not(windows))]
1047    fn write_content(&self, content: &[u8]) -> Result<()> {
1048        match self.try_atomic_write(content) {
1049            Ok(()) => {
1050                log::debug!("Successfully wrote hosts file using atomic write");
1051                Ok(())
1052            }
1053            Err(_) => {
1054                log::debug!("Atomic write failed, falling back to direct write");
1055                self.write_directly(content)
1056            }
1057        }
1058    }
1059
1060    /// Written in place on Windows, after the content is committed.
1061    ///
1062    /// The lock lives on an open handle, and a handle opened by the standard
1063    /// library shares deletion, so a rename over the file would succeed and
1064    /// leave the lock on a retired file while the next writer locks the
1065    /// replacement. In place, the locked handle stays the current file. The
1066    /// file is truncated before it is rewritten, so the new content is first
1067    /// committed next to it, atomically, and the in-place write is repeated
1068    /// from that copy if it does not complete. Retiring the copy is part of
1069    /// the write, but the write already succeeded and was fsynced by the
1070    /// time it happens: a failure to remove it is only ever logged, not
1071    /// reported as a failed write.
1072    ///
1073    /// The pending copy is staged next to the hosts file itself; a caller
1074    /// unable to write there is not privileged enough to write the hosts
1075    /// file either, and gets that `PermissionDenied` back rather than a
1076    /// fallback location a less privileged process could also reach.
1077    #[cfg(windows)]
1078    fn write_content(&self, content: &[u8]) -> Result<()> {
1079        let pending = pending_path(self.target_path);
1080        let mut staging = pending.as_os_str().to_owned();
1081        staging.push(".tmp");
1082        let staging = PathBuf::from(staging);
1083
1084        validate_hosts_path(&staging)?;
1085        if let Err(error) = std::fs::remove_file(&staging)
1086            && error.kind() != io::ErrorKind::NotFound
1087        {
1088            log::warn!(
1089                "Removing a leftover staged hosts rewrite at {}: {error}",
1090                staging.display()
1091            );
1092        }
1093        let mut staged = OpenOptions::new()
1094            .create_new(true)
1095            .write(true)
1096            .open(&staging)?;
1097        staged.write_all(content)?;
1098        staged.sync_all()?;
1099        drop(staged);
1100
1101        validate_hosts_path(&pending)?;
1102        // A leftover pending copy can already be here from an earlier write
1103        // that crashed between publishing it and removing it, or whose
1104        // removal merely failed and was only logged. `rename` needs the
1105        // destination clear; clearing it here treats that leftover as
1106        // ordinary recovery debris rather than letting it break every write
1107        // that follows.
1108        if let Err(error) = std::fs::remove_file(&pending)
1109            && error.kind() != io::ErrorKind::NotFound
1110        {
1111            log::warn!(
1112                "Removing a leftover pending hosts rewrite at {}: {error}",
1113                pending.display()
1114            );
1115        }
1116        std::fs::rename(&staging, &pending)?;
1117        self.write_directly(content)?;
1118        OpenOptions::new()
1119            .write(true)
1120            .open(self.target_path)?
1121            .sync_all()?;
1122        if let Err(error) = std::fs::remove_file(&pending) {
1123            log::warn!(
1124                "Hosts file write to {} succeeded, but the pending copy at {} could not be \
1125                 removed: {error}",
1126                self.target_path.display(),
1127                pending.display()
1128            );
1129        }
1130        Ok(())
1131    }
1132
1133    #[cfg(not(windows))]
1134    fn try_atomic_write(&self, content: &[u8]) -> Result<()> {
1135        let temp_path = self.create_temp_path()?;
1136
1137        std::fs::copy(self.target_path, &temp_path)?;
1138
1139        #[cfg(target_os = "linux")]
1140        self.preserve_selinux_context(&temp_path);
1141
1142        self.write_file(&temp_path, content)?;
1143        std::fs::rename(&temp_path, self.target_path)?;
1144
1145        Ok(())
1146    }
1147
1148    #[cfg(not(windows))]
1149    fn create_temp_path(&self) -> Result<PathBuf> {
1150        let parent = self.target_path.parent().ok_or_else(|| {
1151            HostsFileError::InvalidPath("Path has no parent directory".to_string())
1152        })?;
1153
1154        let timestamp = std::time::SystemTime::now()
1155            .duration_since(std::time::UNIX_EPOCH)
1156            .expect("System time is before Unix epoch")
1157            .as_millis();
1158
1159        let filename = self
1160            .target_path
1161            .file_name()
1162            .ok_or_else(|| HostsFileError::InvalidPath("Path has no filename".to_string()))?;
1163
1164        let temp_filename = format!("{}.tmp{}", filename.to_string_lossy(), timestamp);
1165        Ok(parent.join(temp_filename))
1166    }
1167
1168    #[cfg(target_os = "linux")]
1169    fn preserve_selinux_context(&self, _temp_path: &Path) {
1170        log::trace!("SELinux context preservation not implemented");
1171    }
1172
1173    fn write_directly(&self, content: &[u8]) -> Result<()> {
1174        self.write_file(self.target_path, content)
1175    }
1176
1177    fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
1178        let mut file = OpenOptions::new()
1179            .create(true)
1180            .write(true)
1181            .truncate(true)
1182            .open(path)?;
1183        file.write_all(content)?;
1184        // Durable before it is renamed into place or relied on for recovery:
1185        // a copy that is published before its bytes reach the disk is what a
1186        // crash would restore from.
1187        file.sync_all()?;
1188        Ok(())
1189    }
1190}
1191
1192fn get_default_hosts_path() -> Result<PathBuf> {
1193    let path = get_platform_hosts_path()?;
1194
1195    if !path.exists() {
1196        return Err(HostsFileError::InvalidPath(format!(
1197            "Hosts file not found at {}",
1198            path.display()
1199        )));
1200    }
1201
1202    Ok(path)
1203}
1204
1205fn get_platform_hosts_path() -> Result<PathBuf> {
1206    if cfg!(unix) {
1207        Ok(PathBuf::from("/etc/hosts"))
1208    } else if cfg!(windows) {
1209        let windir = std::env::var("WinDir").map_err(|_| {
1210            HostsFileError::InvalidPath("WinDir environment variable not found".to_string())
1211        })?;
1212        Ok(PathBuf::from(format!(
1213            "{}\\System32\\Drivers\\Etc\\hosts",
1214            windir
1215        )))
1216    } else {
1217        Err(HostsFileError::UnsupportedPlatform)
1218    }
1219}
1220
1221/// Rejects a directory or a symlink at one of this crate's own pending or
1222/// staging siblings (`.kftray-pending`, `.tmp`): those locations are never
1223/// created ahead of time by anything but this crate, and a symlink/reparse
1224/// point already there could only have been planted by another party. Its
1225/// lexical parent being user-writable would let an unprivileged process
1226/// plant one beside the real hosts file for a privileged recovery to
1227/// traverse, so it is refused outright rather than resolved.
1228///
1229/// A missing path is not rejected: a custom path may still be created on
1230/// first write, and a read of one reports an empty document.
1231#[cfg_attr(not(windows), allow(dead_code))]
1232fn validate_hosts_path(path: &Path) -> Result<()> {
1233    let metadata = match std::fs::symlink_metadata(path) {
1234        Ok(metadata) => metadata,
1235        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1236        Err(error) => return Err(error.into()),
1237    };
1238    if metadata.file_type().is_symlink() {
1239        return Err(HostsFileError::InvalidPath(
1240            "Hosts path must not be a symlink".to_string(),
1241        ));
1242    }
1243    if metadata.is_dir() {
1244        return Err(HostsFileError::InvalidPath(
1245            "Expected file path, got directory".to_string(),
1246        ));
1247    }
1248    Ok(())
1249}
1250
1251/// Validates the hosts file's own path (the argument to [`edit_hosts_at`] /
1252/// [`read_hosts_at`]) before it is opened.
1253///
1254/// Unlike the pending/staging siblings [`validate_hosts_path`] guards, this
1255/// path is not something this crate creates: platforms such as NixOS ship
1256/// `/etc/hosts` as a symlink to a generated target, and container/managed
1257/// images do the same, so rejecting a symlink outright here made every read
1258/// and write fail on those systems. A symlink is followed and its resolved
1259/// target validated instead; only a directory, lexically or through the
1260/// link, is rejected. A dangling symlink resolves to "not found", the same
1261/// as a missing path.
1262fn validate_hosts_target_path(path: &Path) -> Result<()> {
1263    match std::fs::metadata(path) {
1264        Ok(metadata) => {
1265            if metadata.is_dir() {
1266                return Err(HostsFileError::InvalidPath(
1267                    "Expected file path, got directory".to_string(),
1268                ));
1269            }
1270            Ok(())
1271        }
1272        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1273        Err(error) => Err(error.into()),
1274    }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use std::io::Write;
1280
1281    use super::*;
1282
1283    #[test]
1284    fn a_duplicated_section_is_refused_rather_than_read_as_its_first() {
1285        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1286        let mut file = HostsFile::new("test");
1287        file.add_owned_entry([127, 0, 0, 1].into(), "a.local", "1")
1288            .unwrap();
1289        file.write_to(&temp_path).unwrap();
1290        // A copy of the whole section pasted below it, as a hand edit could.
1291        let content = std::fs::read_to_string(&temp_path).unwrap();
1292        std::fs::write(&temp_path, format!("{content}{content}")).unwrap();
1293
1294        let error = read_hosts_at(&temp_path, |document| document.section("test"))
1295            .expect_err("two sections for one tag cannot be edited safely");
1296        assert!(
1297            error.to_string().contains("Duplicate section markers"),
1298            "{error}"
1299        );
1300    }
1301
1302    #[test]
1303    fn clear_section_removes_every_duplicated_section() {
1304        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1305        let mut file = HostsFile::new("test");
1306        file.add_owned_entry([127, 0, 0, 1].into(), "a.local", "1")
1307            .unwrap();
1308        file.write_to(&temp_path).unwrap();
1309        // A copy of the whole section pasted below it, as a hand edit could;
1310        // `bounds` refuses this, but the destructive removal path must not.
1311        let content = std::fs::read_to_string(&temp_path).unwrap();
1312        std::fs::write(&temp_path, format!("{content}{content}")).unwrap();
1313
1314        edit_hosts_at(&temp_path, |document| document.clear_section("test")).unwrap();
1315
1316        let remaining = std::fs::read_to_string(&temp_path).unwrap();
1317        assert!(
1318            !remaining.contains("DO NOT EDIT test"),
1319            "clear_section must remove every matching section, not just the first: {remaining}"
1320        );
1321    }
1322
1323    #[test]
1324    fn clear_section_handles_interleaved_markers_without_panicking() {
1325        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1326        // BEGIN, BEGIN, END, END: what a hand edit that pasted one section
1327        // inside another leaves behind. The two pairs nest rather than
1328        // cross, so this must fold into one removal instead of the
1329        // overlapping (0, 2) / (1, 3) ranges a naive index-order zip would
1330        // produce, which `clear_section`'s high-to-low drain cannot apply
1331        // safely.
1332        let content = format!(
1333            "{}\n{}\n127.0.0.1 a.local\n127.0.0.1 b.local\n{}\n{}\n",
1334            HostsDocument::begin_marker("test"),
1335            HostsDocument::begin_marker("test"),
1336            HostsDocument::end_marker("test"),
1337            HostsDocument::end_marker("test"),
1338        );
1339        std::fs::write(&temp_path, content).unwrap();
1340
1341        edit_hosts_at(&temp_path, |document| document.clear_section("test")).unwrap();
1342
1343        let remaining = std::fs::read_to_string(&temp_path).unwrap();
1344        assert!(
1345            !remaining.contains("DO NOT EDIT test"),
1346            "clear_section must remove interleaved sections without panicking: {remaining}"
1347        );
1348    }
1349
1350    #[test]
1351    fn reconciling_owners_merges_duplicated_sections_instead_of_failing() {
1352        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1353        let mut file = HostsFile::new("test");
1354        file.add_owned_entry([127, 0, 0, 1].into(), "a.local", "1")
1355            .unwrap();
1356        file.write_to(&temp_path).unwrap();
1357        // A copy of the whole section pasted below it, as a hand edit could;
1358        // `bounds` refuses this, but a destructive rewrite must fall back to
1359        // merging instead of failing the whole stop.
1360        let content = std::fs::read_to_string(&temp_path).unwrap();
1361        std::fs::write(&temp_path, format!("{content}{content}")).unwrap();
1362
1363        let mut next = HostsFile::new("test");
1364        next.add_owned_entry([127, 0, 0, 2].into(), "b.local", "2")
1365            .unwrap();
1366        let present = next.reconcile_owners_in(&temp_path, &["1"]).unwrap();
1367        assert_eq!(present, HashSet::from(["1".to_owned()]));
1368
1369        let remaining = std::fs::read_to_string(&temp_path).unwrap();
1370        assert_eq!(
1371            remaining.matches("DO NOT EDIT test BEGIN").count(),
1372            1,
1373            "the duplicated sections must be merged into one: {remaining}"
1374        );
1375        assert!(!remaining.contains("a.local"), "{remaining}");
1376        assert!(remaining.contains("b.local"), "{remaining}");
1377    }
1378
1379    #[test]
1380    fn a_read_style_lock_never_creates_a_missing_file() {
1381        let dir = tempfile::tempdir().unwrap();
1382        let path = dir.path().join("hosts");
1383
1384        with_hosts_lock(&path, false, || Ok(())).unwrap();
1385
1386        assert!(
1387            !path.exists(),
1388            "a read-style lock must never create the hosts file it did not find"
1389        );
1390    }
1391
1392    #[test]
1393    fn the_lock_is_released_after_a_panic_in_work() {
1394        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1395
1396        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1397            with_hosts_lock(&temp_path, true, || -> Result<()> {
1398                panic!("work panics before returning");
1399            })
1400        }));
1401        assert!(panicked.is_err(), "the closure above must have panicked");
1402
1403        // A lock left held by the panicked call would make this block until
1404        // the 15s wait budget in `open_locked` expires instead of taking it
1405        // right away.
1406        let start = std::time::Instant::now();
1407        with_hosts_lock(&temp_path, true, || Ok(())).unwrap();
1408        assert!(
1409            start.elapsed() < std::time::Duration::from_secs(1),
1410            "the lock from the panicked call must be released immediately, not held until its \
1411             fd closes"
1412        );
1413    }
1414
1415    #[test]
1416    #[cfg(unix)]
1417    fn a_symlinked_hosts_path_is_followed_to_its_target() {
1418        let dir = tempfile::tempdir().unwrap();
1419        let real = dir.path().join("real-hosts");
1420        std::fs::write(&real, "127.0.0.1 real.local\n").unwrap();
1421        let link = dir.path().join("hosts-link");
1422        std::os::unix::fs::symlink(&real, &link).unwrap();
1423
1424        // NixOS and similar managed systems ship `/etc/hosts` as a symlink
1425        // to a generated target; a read through the link must resolve it
1426        // rather than being rejected outright.
1427        let lines: Vec<String> = read_hosts_at(&link, |document| {
1428            Ok(document
1429                .lines
1430                .iter()
1431                .map(|line| line.text.clone())
1432                .collect())
1433        })
1434        .unwrap();
1435        assert_eq!(lines, vec!["127.0.0.1 real.local".to_string()]);
1436    }
1437
1438    #[test]
1439    #[cfg(unix)]
1440    fn a_hosts_path_resolving_to_a_directory_is_rejected() {
1441        let dir = tempfile::tempdir().unwrap();
1442        let target_dir = dir.path().join("real-dir");
1443        std::fs::create_dir(&target_dir).unwrap();
1444        let link = dir.path().join("hosts-link");
1445        std::os::unix::fs::symlink(&target_dir, &link).unwrap();
1446
1447        let error = read_hosts_at(&link, |document| document.section("test"))
1448            .expect_err("a path resolving to a directory must be rejected, symlink or not");
1449        assert!(error.to_string().contains("directory"), "{error}");
1450    }
1451
1452    #[test]
1453    #[cfg(unix)]
1454    fn a_symlinked_pending_sibling_is_refused() {
1455        // Windows recovery (`open_locked`) and staging (`AtomicFileWriter`)
1456        // both check their `.kftray-pending`/`.tmp` sibling with
1457        // `validate_hosts_path`, which stays strict about symlinks there
1458        // even though the hosts path itself now resolves them: those
1459        // locations are only ever created fresh by this crate, so a symlink
1460        // found there was planted by someone else. This exercises that
1461        // shared check on a `.kftray-pending`-named symlink without
1462        // requiring a Windows target.
1463        let dir = tempfile::tempdir().unwrap();
1464        let real = dir.path().join("real-hosts");
1465        std::fs::write(&real, "127.0.0.1 real.local\n").unwrap();
1466        let pending = dir.path().join("hosts.kftray-pending");
1467        std::os::unix::fs::symlink(&real, &pending).unwrap();
1468
1469        let error =
1470            validate_hosts_path(&pending).expect_err("a symlinked pending sibling must be refused");
1471        assert!(error.to_string().contains("symlink"), "{error}");
1472    }
1473
1474    #[test]
1475    #[cfg(windows)]
1476    fn a_leftover_pending_file_does_not_break_the_next_write() {
1477        let dir = tempfile::tempdir().unwrap();
1478        let path = dir.path().join("hosts");
1479        std::fs::write(&path, "original\n").unwrap();
1480        // As if an earlier write crashed after publishing its pending copy
1481        // but before removing it, or the removal merely failed and was
1482        // only logged.
1483        std::fs::write(pending_path(&path), "stale-pending\n").unwrap();
1484
1485        AtomicFileWriter::new(&path)
1486            .write_content(b"fresh\n")
1487            .unwrap();
1488
1489        assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh\n");
1490        assert!(
1491            !pending_path(&path).exists(),
1492            "the write's own pending copy must be cleaned up: {}",
1493            pending_path(&path).display()
1494        );
1495    }
1496
1497    #[test]
1498    #[cfg(windows)]
1499    fn open_locked_recovery_applies_the_pending_file() {
1500        let dir = tempfile::tempdir().unwrap();
1501        let path = dir.path().join("hosts");
1502        std::fs::write(&path, "original\n").unwrap();
1503
1504        let pending = pending_path(&path);
1505        std::fs::write(&pending, "fresh-pending\n").unwrap();
1506
1507        open_locked(&path, true).unwrap();
1508
1509        assert_eq!(
1510            std::fs::read_to_string(&path).unwrap(),
1511            "fresh-pending\n",
1512            "recovery must apply the pending rewrite sibling to the hosts file"
1513        );
1514        assert!(
1515            !pending.exists(),
1516            "the pending file must be removed once recovery completes"
1517        );
1518    }
1519
1520    #[test]
1521    #[cfg(windows)]
1522    fn a_pending_rewrite_for_one_path_is_never_applied_to_another() {
1523        let dir = tempfile::tempdir().unwrap();
1524        let path_a = dir.path().join("hosts_a");
1525        let path_b = dir.path().join("hosts_b");
1526        std::fs::write(&path_a, "a-original\n").unwrap();
1527        std::fs::write(&path_b, "b-original\n").unwrap();
1528
1529        // As if a write to A was interrupted after the copy was committed
1530        // but before it was applied.
1531        std::fs::write(pending_path(&path_a), "a-pending\n").unwrap();
1532
1533        let a_lines: Vec<String> = read_hosts_at(&path_a, |document| {
1534            Ok(document
1535                .lines
1536                .iter()
1537                .map(|line| line.text.clone())
1538                .collect())
1539        })
1540        .unwrap();
1541        let b_lines: Vec<String> = read_hosts_at(&path_b, |document| {
1542            Ok(document
1543                .lines
1544                .iter()
1545                .map(|line| line.text.clone())
1546                .collect())
1547        })
1548        .unwrap();
1549
1550        assert_eq!(
1551            a_lines,
1552            vec!["a-pending".to_owned()],
1553            "a read sees the pending rewrite as the intended state"
1554        );
1555        assert_eq!(
1556            b_lines,
1557            vec!["b-original".to_owned()],
1558            "a pending rewrite staged for a different path is never applied here"
1559        );
1560        // The read never writes: the file and the pending copy are both
1561        // exactly as they were.
1562        assert_eq!(std::fs::read_to_string(&path_a).unwrap(), "a-original\n");
1563        assert_eq!(
1564            std::fs::read_to_string(pending_path(&path_a)).unwrap(),
1565            "a-pending\n"
1566        );
1567    }
1568
1569    #[test]
1570    fn reconciling_owners_leaves_every_other_line_alone() {
1571        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1572
1573        // What an earlier run, and another writer, left behind: two owned
1574        // lines and one unmarked one.
1575        let mut earlier = HostsFile::new("test");
1576        earlier
1577            .add_owned_entry([127, 0, 0, 1].into(), "a.local", "1")
1578            .unwrap()
1579            .add_owned_entry([127, 0, 0, 1].into(), "b.local", "2")
1580            .unwrap()
1581            .add_entry([127, 0, 0, 1].into(), "plain.local")
1582            .unwrap();
1583        earlier.write_to(&temp_path).unwrap();
1584
1585        // Owner 1 changes its alias; owner 3 appears; owner 2 is untouched.
1586        let mut next = HostsFile::new("test");
1587        next.add_owned_entry([127, 0, 0, 2].into(), "a2.local", "1")
1588            .unwrap()
1589            .add_owned_entry([127, 0, 0, 3].into(), "c.local", "3")
1590            .unwrap();
1591        let present = next.reconcile_owners_in(&temp_path, &["1", "3"]).unwrap();
1592        assert_eq!(
1593            present,
1594            HashSet::from(["1".to_owned()]),
1595            "only the owner that already had a line is reported present"
1596        );
1597
1598        let entries = HostsFile::new("test")
1599            .read_section_from(&temp_path)
1600            .unwrap();
1601        let mut aliases: Vec<(String, Option<String>)> = entries
1602            .into_iter()
1603            .map(|entry| (entry.hostname, entry.owner))
1604            .collect();
1605        aliases.sort();
1606        assert_eq!(
1607            aliases,
1608            vec![
1609                ("a2.local".to_owned(), Some("1".to_owned())),
1610                ("b.local".to_owned(), Some("2".to_owned())),
1611                ("c.local".to_owned(), Some("3".to_owned())),
1612                ("plain.local".to_owned(), None),
1613            ],
1614            "the old line of a rewritten owner is gone, everything else survives"
1615        );
1616
1617        // Staging nothing removes the named owners and nothing else.
1618        let present = HostsFile::new("test")
1619            .reconcile_owners_in(&temp_path, &["2", "missing"])
1620            .unwrap();
1621        assert_eq!(present, HashSet::from(["2".to_owned()]));
1622        let remaining: Vec<String> = HostsFile::new("test")
1623            .read_section_from(&temp_path)
1624            .unwrap()
1625            .into_iter()
1626            .map(|entry| entry.hostname)
1627            .collect();
1628        assert_eq!(remaining, vec!["plain.local", "a2.local", "c.local"]);
1629    }
1630
1631    #[test]
1632    fn an_owner_cannot_change_the_shape_of_the_file() {
1633        let mut hosts_file = HostsFile::new("test");
1634        // An id with a line break would end the comment and start a mapping.
1635        assert!(
1636            hosts_file
1637                .add_owned_entry([127, 0, 0, 1].into(), "a.local", "42\n127.0.0.1 injected")
1638                .is_err()
1639        );
1640        assert!(
1641            hosts_file
1642                .add_owned_entry([127, 0, 0, 1].into(), "a.local", "42 # not-mine")
1643                .is_err()
1644        );
1645        assert!(
1646            hosts_file
1647                .add_owned_entry([127, 0, 0, 1].into(), "a.local\nevil", "42")
1648                .is_err()
1649        );
1650        assert!(
1651            hosts_file
1652                .add_owned_entry([127, 0, 0, 1].into(), "a.local", "42-https-local")
1653                .is_ok()
1654        );
1655        assert!(
1656            HostsFile::new("test")
1657                .reconcile_owners_in("/nonexistent", &["42\n"])
1658                .is_err(),
1659            "removal is validated too, or a bad id could match a comment line"
1660        );
1661    }
1662
1663    #[test]
1664    fn add_entry_rejects_a_bad_hostname() {
1665        let mut hosts_file = HostsFile::new("test");
1666        assert!(
1667            hosts_file
1668                .add_entry([127, 0, 0, 1].into(), "a.local#injected")
1669                .is_err(),
1670            "a `#` would start a comment and swallow the rest of the line"
1671        );
1672        assert!(
1673            hosts_file
1674                .add_entry([127, 0, 0, 1].into(), "a.local evil")
1675                .is_err(),
1676            "whitespace would not stay on the hostname's own column"
1677        );
1678        assert!(hosts_file.is_empty(), "no rejected hostname is staged");
1679        assert!(
1680            hosts_file
1681                .add_entries([127, 0, 0, 1].into(), ["good.local", "bad host"])
1682                .is_err(),
1683            "add_entries must validate every hostname it stages"
1684        );
1685    }
1686
1687    #[test]
1688    fn aliases_sharing_an_address_stay_on_their_own_lines() {
1689        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1690
1691        let mut hosts_file = HostsFile::new("test");
1692        // The SSL aliases of one configuration always share 127.0.0.1.
1693        hosts_file
1694            .add_owned_entry([127, 0, 0, 1].into(), "a.local", "1")
1695            .unwrap();
1696        hosts_file
1697            .add_owned_entry([127, 0, 0, 1].into(), "b.local", "2")
1698            .unwrap();
1699        hosts_file
1700            .add_entry([127, 0, 0, 1].into(), "plain.local")
1701            .unwrap();
1702        hosts_file.write_to(&temp_path).unwrap();
1703
1704        let entries = HostsFile::new("test")
1705            .read_section_from(&temp_path)
1706            .unwrap();
1707        assert_eq!(
1708            entries,
1709            vec![
1710                SectionEntry {
1711                    ip: [127, 0, 0, 1].into(),
1712                    hostname: "a.local".to_owned(),
1713                    owner: Some("1".to_owned()),
1714                },
1715                SectionEntry {
1716                    ip: [127, 0, 0, 1].into(),
1717                    hostname: "b.local".to_owned(),
1718                    owner: Some("2".to_owned()),
1719                },
1720                SectionEntry {
1721                    ip: [127, 0, 0, 1].into(),
1722                    hostname: "plain.local".to_owned(),
1723                    owner: None,
1724                },
1725            ]
1726        );
1727    }
1728
1729    #[test]
1730    fn a_marker_inside_an_ordinary_comment_claims_nothing() {
1731        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1732        temp_file
1733            .write_all(
1734                b"# DO NOT EDIT test BEGIN\n\
1735                  127.0.0.7 real.local # note # kftray-id=42\n\
1736                  # DO NOT EDIT test END\n",
1737            )
1738            .unwrap();
1739
1740        let entries = HostsFile::new("test")
1741            .read_section_from(&temp_path)
1742            .unwrap();
1743
1744        assert_eq!(
1745            entries,
1746            vec![SectionEntry {
1747                ip: [127, 0, 0, 7].into(),
1748                hostname: "real.local".to_owned(),
1749                // A note is not a claim: treating it as one would let a
1750                // reconciliation delete a mapping it does not own.
1751                owner: None,
1752            }]
1753        );
1754    }
1755
1756    #[test]
1757    fn only_marked_lines_are_claimed_by_their_writer() {
1758        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1759        // A section holding one line from the privileged helper, one from this
1760        // writer, and one carrying a hand-written comment.
1761        temp_file
1762            .write_all(
1763                b"# DO NOT EDIT test BEGIN\n\
1764                  127.0.0.5 helper.local\n\
1765                  127.0.0.6 owned.local # kftray-id=41007\n\
1766                  127.0.0.7 noted.local # a note\n\
1767                  # DO NOT EDIT test END\n",
1768            )
1769            .unwrap();
1770
1771        let hosts_file = HostsFile::new("test");
1772        let entries = hosts_file.read_section_from(&temp_path).unwrap();
1773
1774        assert_eq!(
1775            entries,
1776            vec![
1777                SectionEntry {
1778                    ip: [127, 0, 0, 5].into(),
1779                    hostname: "helper.local".to_owned(),
1780                    owner: None,
1781                },
1782                SectionEntry {
1783                    ip: [127, 0, 0, 6].into(),
1784                    hostname: "owned.local".to_owned(),
1785                    owner: Some("41007".to_owned()),
1786                },
1787                // The note is a comment, not two more hostnames: rewriting the
1788                // line with them would put a real alias after a `#`.
1789                SectionEntry {
1790                    ip: [127, 0, 0, 7].into(),
1791                    hostname: "noted.local".to_owned(),
1792                    owner: None,
1793                },
1794            ]
1795        );
1796    }
1797
1798    #[test]
1799    fn an_owned_entry_round_trips_through_the_file() {
1800        let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1801
1802        let mut hosts_file = HostsFile::new("test");
1803        hosts_file
1804            .add_owned_entry([127, 0, 0, 8].into(), "round.local", "9001")
1805            .unwrap();
1806        hosts_file.write_to(&temp_path).unwrap();
1807
1808        let entries = HostsFile::new("test")
1809            .read_section_from(&temp_path)
1810            .unwrap();
1811        assert_eq!(
1812            entries,
1813            vec![SectionEntry {
1814                ip: [127, 0, 0, 8].into(),
1815                hostname: "round.local".to_owned(),
1816                owner: Some("9001".to_owned()),
1817            }]
1818        );
1819    }
1820
1821    #[test]
1822    fn test_hosts_file_write() {
1823        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1824        temp_file.write_all(b"preexisting\ncontent").unwrap();
1825
1826        let mut hosts_file = HostsFile::new("test");
1827        hosts_file
1828            .add_entry([1, 1, 1, 1].into(), "example.com")
1829            .unwrap();
1830
1831        assert!(hosts_file.write_to(&temp_path).unwrap());
1832        assert!(!hosts_file.write_to(&temp_path).unwrap());
1833
1834        let contents = std::fs::read_to_string(&temp_path).unwrap();
1835        assert!(contents.contains("preexisting\ncontent"));
1836        assert!(contents.contains("# DO NOT EDIT test BEGIN"));
1837        assert!(contents.contains("1.1.1.1 example.com"));
1838        assert!(contents.contains("# DO NOT EDIT test END"));
1839    }
1840
1841    #[test]
1842    fn a_missing_custom_hosts_file_is_created_on_first_write() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let path = dir.path().join("hosts");
1845
1846        let mut hosts = HostsFile::new("test");
1847        hosts
1848            .add_entry([127, 0, 0, 1].into(), "fresh.local")
1849            .unwrap();
1850        assert!(hosts.write_to(&path).unwrap());
1851        assert!(
1852            std::fs::read_to_string(&path)
1853                .unwrap()
1854                .contains("127.0.0.1 fresh.local")
1855        );
1856
1857        // A read of a path that still does not exist reports an empty section
1858        // rather than creating anything on its own.
1859        let absent = dir.path().join("never");
1860        assert!(
1861            HostsFile::new("test")
1862                .read_section_from(&absent)
1863                .unwrap()
1864                .is_empty()
1865        );
1866        assert!(!absent.exists(), "a read must not create the file");
1867    }
1868
1869    #[test]
1870    fn test_fluent_api() {
1871        let mut hosts_file = HostsFile::new("test");
1872        hosts_file
1873            .add_entry([127, 0, 0, 1].into(), "localhost")
1874            .unwrap()
1875            .add_entries([192, 168, 1, 1].into(), ["router", "gateway"])
1876            .unwrap();
1877
1878        // One entry per hostname: aliases of one address need their own lines
1879        // so an owner comment cannot swallow the ones after it.
1880        assert_eq!(hosts_file.entries.len(), 3);
1881    }
1882
1883    #[test]
1884    fn untouched_lines_survive_byte_for_byte_and_a_no_op_never_writes() {
1885        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1886        temp_file
1887            .write_all(
1888                b"127.0.0.1 localhost\n\n\
1889                  # DO NOT EDIT test BEGIN\n\
1890                  # a hand-written note\n\
1891                  127.0.0.1   other.local   # note\n\
1892                  127.0.0.2 two.local three.local # kftray-id=9\n\
1893                  \n\
1894                  # DO NOT EDIT test END\n",
1895            )
1896            .unwrap();
1897        let before = std::fs::read_to_string(&temp_path).unwrap();
1898        let modified = std::fs::metadata(&temp_path).unwrap().modified().unwrap();
1899
1900        // Removing an owner that is not there is a no-op: nothing is written,
1901        // which an unprivileged caller depends on to find out it owns nothing.
1902        let present = HostsFile::new("test")
1903            .reconcile_owners_in(&temp_path, &["missing"])
1904            .unwrap();
1905        assert!(present.is_empty());
1906        assert_eq!(std::fs::read_to_string(&temp_path).unwrap(), before);
1907        assert_eq!(
1908            std::fs::metadata(&temp_path).unwrap().modified().unwrap(),
1909            modified
1910        );
1911
1912        // Removing owner 9 keeps the note, the spacing and the blank line of
1913        // everything else exactly as they were.
1914        HostsFile::new("test")
1915            .reconcile_owners_in(&temp_path, &["9"])
1916            .unwrap();
1917        assert_eq!(
1918            std::fs::read_to_string(&temp_path).unwrap(),
1919            "127.0.0.1 localhost\n\n\
1920             # DO NOT EDIT test BEGIN\n\
1921             # a hand-written note\n\
1922             127.0.0.1   other.local   # note\n\
1923             \n\
1924             # DO NOT EDIT test END\n"
1925        );
1926    }
1927
1928    #[test]
1929    fn retaining_rewrites_only_the_lines_it_changes() {
1930        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1931        temp_file
1932            .write_all(
1933                b"# DO NOT EDIT test BEGIN\n\
1934                  127.0.0.1  keep.local   # note\n\
1935                  127.0.0.2 gone.local stay.local\n\
1936                  127.0.0.3 all-gone.local\n\
1937                  # DO NOT EDIT test END\n",
1938            )
1939            .unwrap();
1940
1941        HostsFile::new("test")
1942            .retain_section_in(&temp_path, |entry| {
1943                !matches!(entry.hostname.as_str(), "gone.local" | "all-gone.local")
1944            })
1945            .unwrap();
1946
1947        assert_eq!(
1948            std::fs::read_to_string(&temp_path).unwrap(),
1949            "# DO NOT EDIT test BEGIN\n\
1950             127.0.0.1  keep.local   # note\n\
1951             127.0.0.2 stay.local\n\
1952             # DO NOT EDIT test END\n",
1953            "an untouched line keeps its spacing and note; a partly kept line is rewritten; a \
1954             fully rejected line goes"
1955        );
1956    }
1957
1958    #[test]
1959    fn removing_the_last_alias_removes_the_section_and_its_separator() {
1960        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1961        temp_file.write_all(b"127.0.0.1 localhost\n").unwrap();
1962
1963        let mut hosts = HostsFile::new("test");
1964        hosts
1965            .add_owned_entry([127, 0, 0, 1].into(), "only.local", "1")
1966            .unwrap();
1967        hosts.reconcile_owners_in(&temp_path, &["1"]).unwrap();
1968        HostsFile::new("test")
1969            .reconcile_owners_in(&temp_path, &["1"])
1970            .unwrap();
1971
1972        assert_eq!(
1973            std::fs::read_to_string(&temp_path).unwrap(),
1974            "127.0.0.1 localhost\n",
1975            "add-and-remove cycles must not grow the file"
1976        );
1977    }
1978
1979    #[test]
1980    #[cfg(unix)]
1981    fn a_bounded_retry_reports_a_timeout_instead_of_spinning_forever() {
1982        let mut attempts = 0;
1983        let result: Result<()> =
1984            retry_bounded("test", 3, std::time::Duration::from_millis(0), || {
1985                attempts += 1;
1986                Ok(None)
1987            });
1988
1989        assert_eq!(attempts, 3, "every attempt runs before giving up");
1990        let error = result.expect_err("exhausting every attempt is a timeout, not success");
1991        assert!(error.to_string().contains("Timed out"), "{error}");
1992    }
1993
1994    #[test]
1995    fn an_untouched_crlf_line_keeps_its_terminator_after_an_edit() {
1996        let (mut temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
1997        temp_file
1998            .write_all(
1999                b"127.0.0.1 localhost\r\n\
2000                  # DO NOT EDIT test BEGIN\r\n\
2001                  127.0.0.1 old.local # kftray-id=1\r\n\
2002                  # DO NOT EDIT test END\r\n",
2003            )
2004            .unwrap();
2005
2006        let mut next = HostsFile::new("test");
2007        next.add_owned_entry([127, 0, 0, 2].into(), "new.local", "2")
2008            .unwrap();
2009        next.reconcile_owners_in(&temp_path, &["1"]).unwrap();
2010
2011        assert_eq!(
2012            std::fs::read_to_string(&temp_path).unwrap(),
2013            "127.0.0.1 localhost\r\n\
2014             # DO NOT EDIT test BEGIN\r\n\
2015             127.0.0.2 new.local # kftray-id=2\r\n\
2016             # DO NOT EDIT test END\r\n",
2017            "an untouched line outside the section, and the file's own CRLF terminator, must \
2018             survive an edit made inside it rather than being normalised to LF"
2019        );
2020    }
2021}