Skip to main content

gai_core/
sim.rs

1use crate::types::{
2    NssAction, NssCriterion, NssSource, NssStatus, NsswitchConfig, ResolutionStep, StepResult,
3};
4use std::net::IpAddr;
5
6/// Supplies the actual lookup result for one NSS source. gai-core doesn't
7/// do I/O itself — gai-probe implements this trait with real DNS sockets,
8/// /etc/hosts lookups, mDNS queries, etc. This keeps the decision logic
9/// testable against fixtures without a network in sight.
10pub trait SourceResolver {
11    fn resolve(&mut self, source: &NssSource, name: &str) -> StepResult;
12}
13
14/// Result of walking the nsswitch chain via [`simulate`].
15pub struct SimulationOutcome {
16    /// Every source tried, in order, with its result and whether it
17    /// halted the chain. Sources after a halt are never tried and never
18    /// appear here — that's the whole point of surfacing this list.
19    pub steps: Vec<ResolutionStep>,
20    /// The addresses from the step that halted the chain with a
21    /// `Found` result, or empty if nothing was found.
22    pub final_addresses: Vec<IpAddr>,
23}
24
25impl SimulationOutcome {
26    /// Whether the chain produced at least one address.
27    pub fn resolved(&self) -> bool {
28        !self.final_addresses.is_empty()
29    }
30}
31
32/// Walks the `hosts:` chain from nsswitch.conf in order, applying
33/// [STATUS=action] criteria exactly as glibc's NSS dispatcher does:
34/// each source is tried, its result is classified into a status, and if
35/// that status has a matching criterion the configured action either
36/// halts the chain (`return`) or falls through to the next source
37/// (`continue`, the default when no criterion matches at all).
38pub fn simulate(
39    config: &NsswitchConfig,
40    name: &str,
41    resolver: &mut impl SourceResolver,
42) -> SimulationOutcome {
43    let mut steps = Vec::new();
44    let mut final_addresses = Vec::new();
45
46    for entry in &config.hosts {
47        let result = resolver.resolve(&entry.source, name);
48        let status = classify(&result);
49        let matched_criterion = entry.criteria.iter().find(|c| c.status == status).cloned();
50
51        let halts = match &matched_criterion {
52            Some(NssCriterion {
53                action: NssAction::Return,
54                ..
55            }) => true,
56            // Default glibc behavior without an explicit criterion:
57            // SUCCESS always halts the chain.
58            None => status == NssStatus::Success,
59            _ => false,
60        };
61
62        if let StepResult::Found(ref addrs) = result {
63            final_addresses = addrs.clone();
64        }
65
66        steps.push(ResolutionStep {
67            source: entry.source.clone(),
68            result,
69            halted_chain: if halts { matched_criterion } else { None },
70        });
71
72        if halts {
73            break;
74        }
75    }
76
77    SimulationOutcome {
78        steps,
79        final_addresses,
80    }
81}
82
83fn classify(result: &StepResult) -> NssStatus {
84    match result {
85        StepResult::Found(_) => NssStatus::Success,
86        StepResult::NotFound => NssStatus::NotFound,
87        StepResult::Skipped { .. } => NssStatus::Unavail,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::types::{NssAction, NssCriterion, NssEntry, NssStatus};
95
96    /// A resolver double for tests: scripted answers per source.
97    struct ScriptedResolver {
98        answers: Vec<(NssSource, StepResult)>,
99    }
100
101    impl SourceResolver for ScriptedResolver {
102        fn resolve(&mut self, source: &NssSource, _name: &str) -> StepResult {
103            self.answers
104                .iter()
105                .find(|(s, _)| s == source)
106                .map(|(_, r)| r.clone())
107                .unwrap_or(StepResult::NotFound)
108        }
109    }
110
111    #[test]
112    fn notfound_return_halts_before_dns_is_ever_reached() {
113        let config = NsswitchConfig {
114            hosts: vec![
115                NssEntry {
116                    source: NssSource::Files,
117                    criteria: vec![],
118                },
119                NssEntry {
120                    source: NssSource::Mdns4Minimal,
121                    criteria: vec![NssCriterion {
122                        status: NssStatus::NotFound,
123                        action: NssAction::Return,
124                    }],
125                },
126                NssEntry {
127                    source: NssSource::Dns,
128                    criteria: vec![],
129                },
130            ],
131        };
132        let mut resolver = ScriptedResolver {
133            answers: vec![
134                (NssSource::Files, StepResult::NotFound),
135                (NssSource::Mdns4Minimal, StepResult::NotFound),
136                (
137                    NssSource::Dns,
138                    StepResult::Found(vec!["192.168.1.50".parse().unwrap()]),
139                ),
140            ],
141        };
142
143        let outcome = simulate(&config, "api.local", &mut resolver);
144
145        assert_eq!(outcome.steps.len(), 2, "dns step must never be reached");
146        assert!(!outcome.resolved());
147        assert!(outcome.steps[1].halted_chain.is_some());
148    }
149
150    #[test]
151    fn success_without_criterion_still_halts_by_default() {
152        let config = NsswitchConfig {
153            hosts: vec![
154                NssEntry {
155                    source: NssSource::Files,
156                    criteria: vec![],
157                },
158                NssEntry {
159                    source: NssSource::Dns,
160                    criteria: vec![],
161                },
162            ],
163        };
164        let mut resolver = ScriptedResolver {
165            answers: vec![(
166                NssSource::Files,
167                StepResult::Found(vec!["127.0.0.1".parse().unwrap()]),
168            )],
169        };
170
171        let outcome = simulate(&config, "localhost", &mut resolver);
172
173        assert_eq!(outcome.steps.len(), 1);
174        assert!(outcome.resolved());
175    }
176
177    #[test]
178    fn notfound_without_criterion_falls_through_to_next_source() {
179        // No [NOTFOUND=...] criterion at all on `files` — default glibc
180        // behavior is to continue past NOTFOUND, unlike SUCCESS which
181        // halts by default.
182        let config = NsswitchConfig {
183            hosts: vec![
184                NssEntry {
185                    source: NssSource::Files,
186                    criteria: vec![],
187                },
188                NssEntry {
189                    source: NssSource::Dns,
190                    criteria: vec![],
191                },
192            ],
193        };
194        let mut resolver = ScriptedResolver {
195            answers: vec![
196                (NssSource::Files, StepResult::NotFound),
197                (
198                    NssSource::Dns,
199                    StepResult::Found(vec!["93.184.216.34".parse().unwrap()]),
200                ),
201            ],
202        };
203
204        let outcome = simulate(&config, "example.com", &mut resolver);
205
206        assert_eq!(outcome.steps.len(), 2, "must fall through to dns");
207        assert!(outcome.resolved());
208        assert_eq!(
209            outcome.final_addresses,
210            vec!["93.184.216.34".parse::<IpAddr>().unwrap()]
211        );
212    }
213
214    #[test]
215    fn explicit_continue_action_falls_through_even_on_success() {
216        // [SUCCESS=continue] is unusual but legal — a source can find
217        // something and still not halt the chain if configured that way.
218        let config = NsswitchConfig {
219            hosts: vec![
220                NssEntry {
221                    source: NssSource::Myhostname,
222                    criteria: vec![NssCriterion {
223                        status: NssStatus::Success,
224                        action: NssAction::Continue,
225                    }],
226                },
227                NssEntry {
228                    source: NssSource::Dns,
229                    criteria: vec![],
230                },
231            ],
232        };
233        let mut resolver = ScriptedResolver {
234            answers: vec![
235                (
236                    NssSource::Myhostname,
237                    StepResult::Found(vec!["127.0.1.1".parse().unwrap()]),
238                ),
239                (
240                    NssSource::Dns,
241                    StepResult::Found(vec!["10.0.0.5".parse().unwrap()]),
242                ),
243            ],
244        };
245
246        let outcome = simulate(&config, "myhost", &mut resolver);
247
248        assert_eq!(outcome.steps.len(), 2, "continue must not stop the chain");
249        // dns ran last and is what final_addresses reflects
250        assert_eq!(
251            outcome.final_addresses,
252            vec!["10.0.0.5".parse::<IpAddr>().unwrap()]
253        );
254    }
255
256    #[test]
257    fn full_chain_exhausted_nothing_found_anywhere() {
258        let config = NsswitchConfig {
259            hosts: vec![
260                NssEntry {
261                    source: NssSource::Files,
262                    criteria: vec![],
263                },
264                NssEntry {
265                    source: NssSource::Dns,
266                    criteria: vec![],
267                },
268            ],
269        };
270        let mut resolver = ScriptedResolver {
271            answers: vec![
272                (NssSource::Files, StepResult::NotFound),
273                (NssSource::Dns, StepResult::NotFound),
274            ],
275        };
276
277        let outcome = simulate(&config, "doesnotexist.invalid", &mut resolver);
278
279        assert_eq!(outcome.steps.len(), 2, "every source must be tried");
280        assert!(!outcome.resolved());
281    }
282}