hostfile/
lib.rs

1use std::fs::File;
2use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
3use std::net::{AddrParseError, IpAddr};
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7/**
8 * Host file format:
9 *   File:
10 *     Line |
11 *     Line newline File
12 *
13 *   Line:
14 *     Comment | Entry
15 *
16 *   Comment:
17 *     # .* newline
18 *
19 *   Entry:
20 *     ws* ip ws+ Name (ws+ Names | $)
21 *        (where ip is parsed according to std::net)
22 *
23 *   ws: space | tab
24 *
25 *   Name:
26 *     [a-z.-]+
27 *
28 *   Names:
29 *     Name ws* | Name ws+ Names
30 */
31
32fn parse_ip(input: &str) -> Result<(IpAddr, &str), AddrParseError> {
33    let non_ip_char_idx = input.find(|c: char| c != '.' && c != ':' && !c.is_digit(16));
34    let (ip, remainder) = input.split_at(non_ip_char_idx.unwrap_or(input.len()));
35    Ok((ip.parse()?, remainder))
36}
37
38/// A struct representing a line from /etc/hosts that has a host on it
39#[derive(Debug, Clone, PartialEq)]
40pub struct HostEntry {
41    pub ip: IpAddr,
42    pub names: Vec<String>,
43}
44
45impl FromStr for HostEntry {
46    type Err = String;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        let mut input = s;
50        input = input.trim_start();
51
52        let ip =
53            parse_ip(input).map_err(|err| format!("Couldn't parse a valid IP address: {err}"))?;
54        input = ip.1;
55        let ip = ip.0;
56
57        match input.chars().next() {
58            Some(' ') | Some('\t') => {}
59            _ => {
60                return Err("Expected whitespace after IP".to_string());
61            }
62        }
63        input = input.trim_start();
64
65        let mut names = Vec::new();
66        for name in input.split_whitespace() {
67            // Account for comments at the end of the line
68            match name.chars().next() {
69                Some('#') => break,
70                Some(_) => {}
71                None => unreachable!(),
72            }
73            names.push(name.to_string());
74        }
75
76        Ok(HostEntry { ip, names })
77    }
78}
79
80/// Parse a file using the format described in `man hosts(7)`
81pub fn parse_file(path: &Path) -> Result<Vec<HostEntry>, String> {
82    if !path.exists() || !path.is_file() {
83        return Err(format!(
84            "File ({:?}) does not exist or is not a regular file",
85            path
86        ));
87    }
88
89    let mut file = File::open(path).map_err(|_x| format!("Could not open file ({:?})", path))?;
90    let mut buf = vec![0; 3];
91    let n = file
92        .read(&mut buf)
93        .map_err(|err| format!("Reading header failed {}", err))?;
94    if n == 3 {
95        // The file is at least 3 bytes, check if the file begins with a UTF-8 BOM. If not, reset
96        // the file's position.
97        if &buf != b"\xef\xbb\xbf" {
98            file.seek(SeekFrom::Start(0))
99                .map_err(|err| format!("Seek failed {}", err))?;
100        }
101    }
102
103    let mut entries = Vec::new();
104
105    let lines = BufReader::new(file).lines();
106    let mut line_count = 1;
107    for line in lines {
108        let line = line.map_err(|err| format!("Error reading file at line {line_count}: {err}"))?;
109        let line = line.trim_start();
110        match line.chars().next() {
111            // comment
112            Some('#') => continue,
113            // empty line
114            None => continue,
115            // valid line
116            Some(_) => {
117                entries.push(
118                    line.parse().map_err(|err| {
119                        format!("{err} at line {line_count} with content: '{line}'")
120                    })?,
121                );
122                line_count += 1;
123            }
124        }
125    }
126
127    Ok(entries)
128}
129
130/// Parse system hostfile.
131///
132/// - `/etc/hosts` on Unix.
133/// - `C:\Windows\system32\drivers\etc\hosts` on Windows.
134pub fn parse_hostfile() -> Result<Vec<HostEntry>, String> {
135    parse_file(&get_hostfile_path()?)
136}
137
138/// Get path to the system hostfile.
139pub fn get_hostfile_path() -> Result<PathBuf, String> {
140    #[cfg(not(windows))]
141    {
142        Ok(PathBuf::from("/etc/hosts"))
143    }
144
145    #[cfg(windows)]
146    {
147        // Implementation adapted from cargo's `home`.
148        // See https://crates.io/crates/home
149        use std::ffi::OsString;
150        use std::os::windows::ffi::OsStringExt;
151        use std::ptr::null_mut;
152        use std::slice;
153        use windows_sys::Win32::{
154            Foundation::S_OK,
155            System::Com::CoTaskMemFree,
156            UI::Shell::{FOLDERID_System, SHGetKnownFolderPath, KF_FLAG_DONT_VERIFY},
157        };
158
159        extern "C" {
160            fn wcslen(buf: *const u16) -> usize;
161        }
162
163        let mut ptr = null_mut::<u16>();
164        let ret = unsafe {
165            SHGetKnownFolderPath(
166                &FOLDERID_System,
167                KF_FLAG_DONT_VERIFY as u32,
168                null_mut(),
169                &mut ptr,
170            )
171        };
172
173        match ret {
174            S_OK => {
175                let path_slice = unsafe { slice::from_raw_parts(ptr, wcslen(ptr)) };
176                let os_str = OsString::from_wide(path_slice);
177                unsafe { CoTaskMemFree(ptr.cast()) };
178                let mut pathbuf = PathBuf::from(&os_str);
179                pathbuf.push("drivers\\etc\\hosts");
180                Ok(pathbuf)
181            }
182            _ => {
183                // free any allocated memory even on failure (a null ptr is a no-op for `CoTaskMemFree`)
184                unsafe { CoTaskMemFree(ptr.cast()) };
185                Err(format!(
186                    "Could not get path to Windows hosts file: {}",
187                    std::io::Error::last_os_error(),
188                ))
189            }
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    extern crate mktemp;
197    use mktemp::Temp;
198
199    use std::io::{Seek, SeekFrom, Write};
200    use std::net::{Ipv4Addr, Ipv6Addr};
201
202    use super::*;
203
204    #[test]
205    fn parse_ipv4() {
206        let input = "127.0.0.1";
207        assert_eq!(
208            parse_ip(input),
209            Ok((IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), ""))
210        );
211    }
212
213    #[test]
214    fn parse_ipv6() {
215        let input = "::1";
216        assert_eq!(
217            parse_ip(input),
218            Ok((IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), ""))
219        );
220    }
221
222    #[test]
223    fn parse_entry() {
224        assert_eq!(
225            "127.0.0.1 localhost".parse(),
226            Ok(HostEntry {
227                ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
228                names: vec!(String::from("localhost")),
229            })
230        );
231    }
232
233    #[test]
234    fn parse_entry_multiple_names() {
235        assert_eq!(
236            "127.0.0.1 localhost home  ".parse(),
237            Ok(HostEntry {
238                ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
239                names: vec!(String::from("localhost"), String::from("home")),
240            })
241        );
242    }
243
244    #[test]
245    fn parse_entry_ipv6() {
246        assert_eq!(
247            "::1 localhost".parse(),
248            Ok(HostEntry {
249                ip: IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)),
250                names: vec!(String::from("localhost")),
251            })
252        );
253    }
254
255    #[test]
256    fn parse_entry_with_ws_and_comments() {
257        assert_eq!(
258            "    ::1 \tlocalhost # comment".parse(),
259            Ok(HostEntry {
260                ip: IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)),
261                names: vec!(String::from("localhost")),
262            })
263        );
264    }
265
266    #[test]
267    fn test_parse_file() {
268        let temp_file = Temp::new_file().unwrap();
269        let temp_path = temp_file.as_path();
270        let mut file = File::create(temp_path).unwrap();
271
272        write!(
273            file,
274            "\
275            # This is a sample hosts file\n\
276               \n# Sometimes hosts files can have wonky spacing
277            127.0.0.1       localhost\n\
278            ::1             localhost\n\
279            255.255.255.255 broadcast\n\
280            \n\
281            # Comments can really be anywhere\n\
282            bad:dad::ded    multiple hostnames for address\n\
283            1.1.1.1\ttabSeperatedHostname\n\
284            1.1.1.2\t tabAndSpaceSeparatedHostName\n\
285            \t1.1.1.3\t\t\tlineStartsWithTab\n\
286              1.1.1.4 lineStartsWithSpace\n\
287            1.1.1.5 skip_blank_line
288        "
289        )
290        .expect("Could not write to temp file");
291
292        assert_eq!(
293            parse_file(&temp_path),
294            Ok(vec!(
295                HostEntry {
296                    ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
297                    names: vec!(String::from("localhost")),
298                },
299                HostEntry {
300                    ip: IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)),
301                    names: vec!(String::from("localhost")),
302                },
303                HostEntry {
304                    ip: IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)),
305                    names: vec!(String::from("broadcast")),
306                },
307                HostEntry {
308                    ip: IpAddr::V6(Ipv6Addr::new(0xbad, 0xdad, 0, 0, 0, 0, 0, 0xded)),
309                    names: vec!(
310                        String::from("multiple"),
311                        String::from("hostnames"),
312                        String::from("for"),
313                        String::from("address")
314                    ),
315                },
316                HostEntry {
317                    ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
318                    names: vec!(String::from("tabSeperatedHostname")),
319                },
320                HostEntry {
321                    ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 2)),
322                    names: vec!(String::from("tabAndSpaceSeparatedHostName")),
323                },
324                HostEntry {
325                    ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
326                    names: vec!(String::from("lineStartsWithTab")),
327                },
328                HostEntry {
329                    ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 4)),
330                    names: vec!(String::from("lineStartsWithSpace")),
331                },
332                HostEntry {
333                    ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 5)),
334                    names: vec!(String::from("skip_blank_line")),
335                },
336            ))
337        );
338    }
339
340    #[test]
341    fn test_parse_file_with_bom() {
342        let temp_file = Temp::new_file().unwrap();
343        let temp_path = temp_file.as_path();
344        let mut file = File::create(temp_path).unwrap();
345        file.write_all(b"\xef\xbb\xbf\n127.0.0.1       localhost\n")
346            .expect("Could not write to temp file");
347        parse_file(&temp_path).unwrap();
348    }
349
350    #[test]
351    fn test_parse_file_errors() {
352        let temp_file = Temp::new_file().unwrap();
353        let temp_path = temp_file.as_path();
354        let mut file = File::create(temp_path).unwrap();
355
356        write!(file, "127.0.0.1localhost\n").expect("");
357        assert_eq!(
358            parse_file(&temp_path),
359            Err(
360                "Expected whitespace after IP at line 1 with content: '127.0.0.1localhost'"
361                    .to_string()
362            )
363        );
364
365        file.set_len(0).expect("Could not truncate file");
366        file.seek(SeekFrom::Start(0)).expect("");
367        write!(file, "127.0.0 localhost\n").expect("");
368        assert_eq!(
369            parse_file(&temp_path),
370            Err("Couldn't parse a valid IP address: invalid IP address syntax at line 1 with content: '127.0.0 localhost'".to_string())
371        );
372
373        file.set_len(0).expect("");
374        file.seek(SeekFrom::Start(0)).expect("");
375        write!(file, "127.0.0 local\nhost\n").expect("");
376        assert_eq!(
377            parse_file(&temp_path),
378            Err("Couldn't parse a valid IP address: invalid IP address syntax at line 1 with content: '127.0.0 local'".to_string())
379        );
380
381        file.set_len(0).expect("");
382        file.seek(SeekFrom::Start(0)).expect("");
383        write!(file, "127.0.0.1 localhost\nlocalhost myhost").expect("");
384        assert_eq!(
385            parse_file(&temp_path),
386            Err("Couldn't parse a valid IP address: invalid IP address syntax at line 2 with content: 'localhost myhost'".to_string())
387        );
388
389        let temp_dir = Temp::new_dir().unwrap();
390        let temp_dir_path = temp_dir.as_path();
391        assert_eq!(
392            parse_file(&temp_dir_path),
393            Err(format!(
394                "File ({:?}) does not exist or is not a regular file",
395                temp_dir_path
396            ))
397        );
398    }
399
400    #[test]
401    fn test_clone() {
402        let host_entry = HostEntry {
403            ip: IpAddr::V4(Ipv4Addr::new(192, 168, 42, 42)),
404            names: vec![String::from("comp1"), String::from("computer1")],
405        };
406        let cloned = host_entry.clone();
407        assert_eq!(host_entry, cloned)
408    }
409
410    #[test]
411    fn test_get_hostfile_path() {
412        let maybe_path = get_hostfile_path();
413        assert!(maybe_path.is_ok());
414        assert!(maybe_path.unwrap().exists());
415    }
416
417    // The next test only runs on GitHub Actions.
418    //
419    // Unix systems *typically* include localhost in /etc/hosts,
420    // but we only assume that for GitHub Actions hostfile.
421    //
422    // In GitHub Actions, the Windows runnner includes an entry like
423    // "10.1.0.85 <long-whatever>.cloudapp.net", localhost is commented.
424    #[test_with::env(GITHUB_ACTIONS)]
425    #[test]
426    fn test_parse_hostfile() {
427        let maybe_hostfile = parse_hostfile();
428        assert!(maybe_hostfile.is_ok());
429        let hostfile = maybe_hostfile.unwrap();
430        assert!(!hostfile.is_empty());
431
432        #[cfg(not(windows))]
433        {
434            let localhost = hostfile
435                .iter()
436                .find(|entry| entry.names.contains(&String::from("localhost")));
437            assert!(localhost.is_some());
438        }
439    }
440}