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 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
65const OWNER_MARKER: &str = " # kftray-id=";
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct SectionEntry {
71 pub ip: IpAddr,
72 pub hostname: String,
73 pub owner: Option<String>,
76}
77
78pub 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
95pub 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
105fn 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 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
146fn 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#[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#[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#[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#[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#[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 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#[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
371pub struct HostsDocument {
380 lines: Vec<Line>,
381 original: Vec<Line>,
382 ends_with_newline: bool,
384}
385
386struct 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 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 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 #[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 pub fn is_dirty(&self) -> bool {
470 self.lines != self.original
471 }
472
473 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 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 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 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 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 §ions {
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 fn parse_line(line: &str) -> Option<ParsedLine> {
609 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 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 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 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 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 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 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 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 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 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
881pub fn edit_hosts<T>(edit: impl FnOnce(&mut HostsDocument) -> Result<T>) -> Result<T> {
884 edit_hosts_at(&get_default_hosts_path()?, edit)
885}
886
887pub 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
900pub fn read_hosts<T>(read: impl FnOnce(&HostsDocument) -> Result<T>) -> Result<T> {
906 read_hosts_at(&get_default_hosts_path()?, read)
907}
908
909pub fn read_hosts_at<T>(path: &Path, read: impl FnOnce(&HostsDocument) -> Result<T>) -> Result<T> {
911 validate_hosts_target_path(path)?;
912 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
924pub 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 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 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 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 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 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 #[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 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 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#[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
1251fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}