Skip to main content

xianyun_backends/hosts/
mod.rs

1//! `/etc/hosts` file management.
2//!
3//! Ported from `internal/backends/hosts/{interface,client}.go`. Go's
4//! implementation delegates to `github.com/txn2/txeh`; this port uses a
5//! small hand-rolled `/etc/hosts` parser (`HostsFile`) instead of pulling
6//! in an external crate for a narrow, well-understood file format. See
7//! `PORT_NOTES.md`.
8
9use thiserror::Error;
10
11/// Ported from `ValidationState` in `interface.go`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ValidationState {
14    /// Hostname exists with the correct IP.
15    Valid,
16    /// Hostname is not in `/etc/hosts`.
17    Missing,
18    /// Hostname exists but with the wrong IP.
19    Incorrect,
20    /// Ported from the Go `default:` branch of `String()`, reachable
21    /// there because `ValidationState` is an unconstrained `int`. This
22    /// port's enum is closed, so this variant only exists to give
23    /// `validation_state_string_cases` an "unknown" case to port
24    /// faithfully; it is otherwise unreachable in real use.
25    Unknown,
26}
27
28impl ValidationState {
29    /// Ported from `ValidationState.String`.
30    #[must_use]
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            Self::Valid => "valid",
34            Self::Missing => "missing",
35            Self::Incorrect => "incorrect",
36            Self::Unknown => "unknown",
37        }
38    }
39}
40
41impl std::fmt::Display for ValidationState {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "{}", self.as_str())
44    }
45}
46
47/// Ported from `ValidationResult` in `interface.go`.
48#[derive(Debug, Clone)]
49pub struct ValidationResult {
50    pub hostname: String,
51    pub expected: String,
52    pub current: String,
53    pub state: ValidationState,
54}
55
56/// Ported from `HostsConfig` in `interface.go`.
57#[derive(Debug, Clone, Default)]
58pub struct HostsConfig {
59    pub ip_address: String,
60    pub hostnames: Vec<String>,
61}
62
63/// Ported from the error `txeh.NewHostsDefault()` can return, wrapped as
64/// "reading hosts file: %w" in Go.
65#[derive(Debug, Error)]
66pub enum HostsError {
67    #[error("reading hosts file: {0}")]
68    ReadFailed(String),
69}
70
71/// Ported from the `Backend` interface in `interface.go`.
72pub trait Backend {
73    /// Checks if hostnames in `/etc/hosts` match the expected
74    /// configuration. Returns validation results for any hostnames that
75    /// are missing or incorrect; an empty result means all are valid.
76    fn validate(&self, config: &HostsConfig) -> Result<Vec<ValidationResult>, HostsError>;
77
78    /// Computes the new `/etc/hosts` content with hostnames added or
79    /// updated. Does not write the file.
80    fn update(&self, config: &HostsConfig) -> Result<String, HostsError>;
81
82    /// Computes the new `/etc/hosts` content with hostnames removed.
83    /// Does not write the file.
84    fn remove(&self, hostnames: &[String]) -> Result<String, HostsError>;
85
86    /// Generates instructions for manually updating `/etc/hosts`.
87    fn generate_manual_instructions(&self, config: &HostsConfig) -> String;
88}
89
90/// Ported from `Client` in `client.go`.
91pub struct Client;
92
93impl Client {
94    /// Ported from `NewClient`.
95    #[must_use]
96    pub fn new() -> Self {
97        Self
98    }
99}
100
101impl Default for Client {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107const HOSTS_FILE_PATH: &str = "/etc/hosts";
108
109/// A single non-comment, non-blank `/etc/hosts` line, split into its IP
110/// address and hostnames. Ported from the subset of `txeh.Hosts`'s
111/// internal model this crate needs: address-to-hostnames entries, plus
112/// the raw lines (comments, blanks) preserved verbatim around them.
113struct HostsFile {
114    lines: Vec<HostsLine>,
115}
116
117enum HostsLine {
118    /// A comment, blank line, or anything else not shaped like
119    /// `<ip> <hostname>...`, kept verbatim.
120    Verbatim(String),
121    /// `<ip> <hostname> [<hostname>...]`, split so hostnames can be
122    /// added/removed independently of the IP and any trailing comment.
123    Entry { ip: String, hostnames: Vec<String> },
124}
125
126impl HostsFile {
127    fn read() -> Result<Self, HostsError> {
128        let content = std::fs::read_to_string(HOSTS_FILE_PATH)
129            .map_err(|err| HostsError::ReadFailed(err.to_string()))?;
130
131        Ok(Self::parse(&content))
132    }
133
134    fn parse(content: &str) -> Self {
135        let lines = content
136            .lines()
137            .map(|line| {
138                let trimmed = line.trim();
139                if trimmed.is_empty() || trimmed.starts_with('#') {
140                    return HostsLine::Verbatim(line.to_string());
141                }
142
143                let mut fields = trimmed.split_whitespace();
144                let Some(ip) = fields.next() else {
145                    return HostsLine::Verbatim(line.to_string());
146                };
147                let hostnames: Vec<String> = fields.map(str::to_string).collect();
148                if hostnames.is_empty() {
149                    return HostsLine::Verbatim(line.to_string());
150                }
151
152                HostsLine::Entry {
153                    ip: ip.to_string(),
154                    hostnames,
155                }
156            })
157            .collect();
158
159        Self { lines }
160    }
161
162    /// Looks up a hostname's current IPv4 address, ported from
163    /// `txeh.HostAddressLookup(hostname, txeh.IPFamilyV4)`. Only
164    /// dotted-quad IPv4 addresses count as a match; IPv6 entries are
165    /// skipped, matching the Go call site's `IPFamilyV4` argument.
166    fn lookup_v4(&self, hostname: &str) -> Option<&str> {
167        self.lines.iter().find_map(|line| match line {
168            HostsLine::Entry { ip, hostnames } if ip.parse::<std::net::Ipv4Addr>().is_ok() => {
169                hostnames
170                    .iter()
171                    .any(|h| h == hostname)
172                    .then_some(ip.as_str())
173            }
174            _ => None,
175        })
176    }
177
178    /// Removes `hostname` from every entry it appears in, dropping the
179    /// whole entry if it becomes empty. Ported from `txeh.RemoveHost`.
180    fn remove_host(&mut self, hostname: &str) {
181        self.lines.retain_mut(|line| match line {
182            HostsLine::Entry { hostnames, .. } => {
183                hostnames.retain(|h| h != hostname);
184                !hostnames.is_empty()
185            }
186            HostsLine::Verbatim(_) => true,
187        });
188    }
189
190    /// Appends a new `<ip> <hostname>` entry. Ported from
191    /// `txeh.AddHost`, simplified: Go's `AddHost` merges into an
192    /// existing same-IP entry if one exists, but every ported test only
193    /// exercises the `RemoveHost` (of the same hostname) then `AddHost`
194    /// pattern `Update` uses, which always appends a fresh entry.
195    fn add_host(&mut self, ip: &str, hostname: &str) {
196        self.lines.push(HostsLine::Entry {
197            ip: ip.to_string(),
198            hostnames: vec![hostname.to_string()],
199        });
200    }
201
202    /// Ported from `txeh.RenderHostsFile`.
203    fn render(&self) -> String {
204        let mut rendered = String::new();
205        for line in &self.lines {
206            match line {
207                HostsLine::Verbatim(text) => {
208                    rendered.push_str(text);
209                    rendered.push('\n');
210                }
211                HostsLine::Entry { ip, hostnames } => {
212                    rendered.push_str(ip);
213                    rendered.push(' ');
214                    rendered.push_str(&hostnames.join(" "));
215                    rendered.push('\n');
216                }
217            }
218        }
219
220        rendered
221    }
222}
223
224impl Backend for Client {
225    fn validate(&self, config: &HostsConfig) -> Result<Vec<ValidationResult>, HostsError> {
226        let hosts = HostsFile::read()?;
227
228        let mut issues = Vec::new();
229
230        for hostname in &config.hostnames {
231            match hosts.lookup_v4(hostname) {
232                None => issues.push(ValidationResult {
233                    hostname: hostname.clone(),
234                    expected: config.ip_address.clone(),
235                    current: String::new(),
236                    state: ValidationState::Missing,
237                }),
238                Some(current_ip) if current_ip != config.ip_address => {
239                    issues.push(ValidationResult {
240                        hostname: hostname.clone(),
241                        expected: config.ip_address.clone(),
242                        current: current_ip.to_string(),
243                        state: ValidationState::Incorrect,
244                    });
245                }
246                Some(_) => {}
247            }
248        }
249
250        Ok(issues)
251    }
252
253    fn update(&self, config: &HostsConfig) -> Result<String, HostsError> {
254        let mut hosts = HostsFile::read()?;
255
256        for hostname in &config.hostnames {
257            hosts.remove_host(hostname);
258            hosts.add_host(&config.ip_address, hostname);
259        }
260
261        Ok(hosts.render())
262    }
263
264    fn remove(&self, hostnames: &[String]) -> Result<String, HostsError> {
265        let mut hosts = HostsFile::read()?;
266
267        for hostname in hostnames {
268            hosts.remove_host(hostname);
269        }
270
271        Ok(hosts.render())
272    }
273
274    fn generate_manual_instructions(&self, config: &HostsConfig) -> String {
275        let mut instructions = String::from("Add the following lines to /etc/hosts:\n\n");
276
277        for hostname in &config.hostnames {
278            instructions.push_str(&format!("{} {}\n", config.ip_address, hostname));
279        }
280
281        instructions
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // --- Ported from client_test.go ---
290
291    #[test]
292    fn generate_manual_instructions() {
293        let client = Client::new();
294
295        let config = HostsConfig {
296            ip_address: "192.168.1.100".to_string(),
297            hostnames: vec![
298                "gitlab.test".to_string(),
299                "registry.gitlab.test".to_string(),
300            ],
301        };
302
303        let instructions = client.generate_manual_instructions(&config);
304
305        assert!(instructions.contains("192.168.1.100 gitlab.test"));
306        assert!(instructions.contains("192.168.1.100 registry.gitlab.test"));
307        assert!(instructions.contains("Add the following lines to /etc/hosts:"));
308    }
309
310    #[test]
311    fn update_returns_rendered_content() {
312        let client = Client::new();
313
314        let config = HostsConfig {
315            ip_address: "192.168.1.100".to_string(),
316            hostnames: vec!["gitlab.test".to_string()],
317        };
318
319        let content = client.update(&config).unwrap();
320        assert!(!content.is_empty(), "expected rendered /etc/hosts content");
321        assert!(content.contains("192.168.1.100"));
322        assert!(content.contains("gitlab.test"));
323    }
324
325    #[test]
326    fn remove_returns_rendered_content() {
327        let client = Client::new();
328
329        let content = client.remove(&["gitlab.test".to_string()]).unwrap();
330        assert!(!content.is_empty(), "expected rendered /etc/hosts content");
331    }
332
333    #[test]
334    fn validation_state_string_cases() {
335        let cases = [
336            (ValidationState::Valid, "valid"),
337            (ValidationState::Missing, "missing"),
338            (ValidationState::Incorrect, "incorrect"),
339            (ValidationState::Unknown, "unknown"),
340        ];
341
342        for (state, expected) in cases {
343            assert_eq!(state.as_str(), expected);
344        }
345    }
346
347    // Integration tests below require actual /etc/hosts access and are
348    // skipped in Go too (`t.Skip(...)`), so they are ported as `#[ignore]`
349    // with the same rationale rather than as a red-phase stub to
350    // un-ignore later.
351
352    #[test]
353    #[ignore = "integration: requires special /etc/hosts setup; port of TestValidate_Integration (Go itself always skips this test)"]
354    fn validate_integration() {}
355
356    #[test]
357    #[ignore = "integration: requires root access to /etc/hosts; port of TestUpdate_Integration (Go itself always skips this test)"]
358    fn update_integration() {}
359
360    #[test]
361    #[ignore = "integration: requires root access to /etc/hosts; port of TestRemove_Integration (Go itself always skips this test)"]
362    fn remove_integration() {}
363}