1use thiserror::Error;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ValidationState {
14 Valid,
16 Missing,
18 Incorrect,
20 Unknown,
26}
27
28impl ValidationState {
29 #[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#[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#[derive(Debug, Clone, Default)]
58pub struct HostsConfig {
59 pub ip_address: String,
60 pub hostnames: Vec<String>,
61}
62
63#[derive(Debug, Error)]
66pub enum HostsError {
67 #[error("reading hosts file: {0}")]
68 ReadFailed(String),
69}
70
71pub trait Backend {
73 fn validate(&self, config: &HostsConfig) -> Result<Vec<ValidationResult>, HostsError>;
77
78 fn update(&self, config: &HostsConfig) -> Result<String, HostsError>;
81
82 fn remove(&self, hostnames: &[String]) -> Result<String, HostsError>;
85
86 fn generate_manual_instructions(&self, config: &HostsConfig) -> String;
88}
89
90pub struct Client;
92
93impl Client {
94 #[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
109struct HostsFile {
114 lines: Vec<HostsLine>,
115}
116
117enum HostsLine {
118 Verbatim(String),
121 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 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 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 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 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 #[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 #[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}