1use std::fmt;
71use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
72use std::str::FromStr;
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub struct IpGateDecision {
86 pub is_whitelisted: bool,
88 pub is_exempt: bool,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum IpGateDenial {
97 Blacklisted,
99 NotInWhitelist,
102}
103
104impl IpGateDenial {
105 #[must_use]
108 pub const fn reason(self) -> &'static str {
109 match self {
110 Self::Blacklisted => "IP is blacklisted",
111 Self::NotInWhitelist => "IP not in whitelist",
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum IpGateVerdict {
119 Allowed(IpGateDecision),
121 Denied(IpGateDenial),
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127enum Entry {
128 Exact(IpAddr),
129 Network(IpNet),
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) struct IpNet {
135 pub(crate) addr: IpAddr,
136 pub(crate) prefix: u8,
137}
138
139impl IpNet {
140 pub(crate) fn parse(text: &str) -> Option<Self> {
146 let (addr_part, prefix_part) = text.split_once('/')?;
147 let addr = IpAddr::from_str(addr_part).ok()?;
148 let prefix = u8::from_str(prefix_part).ok()?;
149 let bits = family_bits(addr);
150 if prefix > bits {
151 return None;
152 }
153 Some(Self {
154 addr: masked(addr, prefix),
155 prefix,
156 })
157 }
158
159 pub(crate) fn contains(&self, addr: IpAddr) -> bool {
162 match (self.addr, addr) {
163 (IpAddr::V4(net), IpAddr::V4(ip)) => {
164 u32::from(net) == (u32::from(ip) & v4_mask(self.prefix))
165 }
166 (IpAddr::V6(net), IpAddr::V6(ip)) => {
167 u128::from(net) == (u128::from(ip) & v6_mask(self.prefix))
168 }
169 _ => false,
170 }
171 }
172}
173
174fn parse_entry(text: &str) -> Option<Entry> {
176 if text.contains('/') {
177 return Some(Entry::Network(IpNet::parse(text)?));
178 }
179 Some(Entry::Exact(canonical(IpAddr::from_str(text).ok()?)))
180}
181
182pub(crate) fn parse_network_entry(text: &str) -> Option<IpNet> {
187 match parse_entry(text)? {
188 Entry::Exact(addr) => Some(IpNet {
189 addr,
190 prefix: family_bits(addr),
191 }),
192 Entry::Network(net) => Some(net),
193 }
194}
195
196pub(crate) const fn canonical(addr: IpAddr) -> IpAddr {
201 match addr {
202 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
203 Some(v4) => IpAddr::V4(v4),
204 None => IpAddr::V6(v6),
205 },
206 IpAddr::V4(v4) => IpAddr::V4(v4),
207 }
208}
209
210const fn family_bits(addr: IpAddr) -> u8 {
212 match addr {
213 IpAddr::V4(_) => 32,
214 IpAddr::V6(_) => 128,
215 }
216}
217
218fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
220 match addr {
221 IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
222 IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
223 }
224}
225
226const fn v4_mask(prefix: u8) -> u32 {
228 if prefix == 0 {
229 0
230 } else {
231 u32::MAX << (32 - prefix)
232 }
233}
234
235const fn v6_mask(prefix: u8) -> u128 {
237 if prefix == 0 {
238 0
239 } else {
240 u128::MAX << (128 - prefix)
241 }
242}
243
244fn entry_matches(entry: &Entry, addr: IpAddr) -> bool {
252 match entry {
253 Entry::Exact(expected) => *expected == canonical(addr),
254 Entry::Network(net) => net.contains(canonical(addr)) || net.contains(addr),
255 }
256}
257
258fn list_matches(entries: &[Entry], addr: IpAddr) -> bool {
261 entries.iter().any(|entry| entry_matches(entry, addr))
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct IpGateError {
267 pub list: &'static str,
270 pub entry: String,
272}
273
274impl fmt::Display for IpGateError {
275 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276 write!(
277 f,
278 "invalid {} entry '{}': expected an IP address or CIDR range",
279 self.list, self.entry
280 )
281 }
282}
283
284impl std::error::Error for IpGateError {}
285
286#[derive(Debug, Clone, Default, PartialEq, Eq)]
294pub struct IpGateConfig {
295 whitelist: Vec<Entry>,
296 blacklist: Vec<Entry>,
297 exempt_ips: Vec<Entry>,
298}
299
300impl IpGateConfig {
301 pub fn new<W, B, E>(whitelist: W, blacklist: B, exempt_ips: E) -> Result<Self, IpGateError>
313 where
314 W: IntoIterator,
315 W::Item: AsRef<str>,
316 B: IntoIterator,
317 B::Item: AsRef<str>,
318 E: IntoIterator,
319 E::Item: AsRef<str>,
320 {
321 Ok(Self {
322 whitelist: parse_list("whitelist", whitelist)?,
323 blacklist: parse_list("blacklist", blacklist)?,
324 exempt_ips: parse_list("exempt_ips", exempt_ips)?,
325 })
326 }
327
328 #[must_use]
336 pub fn evaluate(&self, ip: IpAddr) -> IpGateVerdict {
337 if !self.whitelist.is_empty() {
338 if !list_matches(&self.whitelist, ip) {
339 return IpGateVerdict::Denied(IpGateDenial::NotInWhitelist);
340 }
341 return IpGateVerdict::Allowed(IpGateDecision {
342 is_whitelisted: true,
343 is_exempt: list_matches(&self.exempt_ips, ip),
344 });
345 }
346 if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
347 return IpGateVerdict::Denied(IpGateDenial::Blacklisted);
348 }
349 IpGateVerdict::Allowed(IpGateDecision {
350 is_whitelisted: false,
351 is_exempt: list_matches(&self.exempt_ips, ip),
352 })
353 }
354}
355
356fn parse_list<I>(list: &'static str, entries: I) -> Result<Vec<Entry>, IpGateError>
358where
359 I: IntoIterator,
360 I::Item: AsRef<str>,
361{
362 let mut parsed = Vec::new();
363 for entry in entries {
364 let entry = entry.as_ref();
365 match parse_entry(entry) {
366 Some(parsed_entry) => parsed.push(parsed_entry),
367 None => {
368 return Err(IpGateError {
369 list,
370 entry: entry.to_owned(),
371 });
372 }
373 }
374 }
375 Ok(parsed)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 const NIL: [&str; 0] = [];
384
385 fn ip(text: &str) -> IpAddr {
386 IpAddr::from_str(text).expect("test address")
387 }
388
389 #[test]
390 fn parse_entry_forms() {
391 assert_eq!(
392 parse_entry("203.0.113.7"),
393 Some(Entry::Exact(ip("203.0.113.7")))
394 );
395 assert_eq!(
396 parse_entry("198.51.100.16/28"),
397 Some(Entry::Network(IpNet {
398 addr: ip("198.51.100.16"),
399 prefix: 28
400 }))
401 );
402 assert_eq!(
404 parse_entry("198.51.100.20/28"),
405 parse_entry("198.51.100.16/28")
406 );
407 assert_eq!(
409 parse_entry("::ffff:203.0.113.7"),
410 Some(Entry::Exact(ip("203.0.113.7")))
411 );
412 assert_eq!(
414 parse_entry("::ffff:0:0/96"),
415 Some(Entry::Network(IpNet {
416 addr: ip("::ffff:0:0"),
417 prefix: 96
418 }))
419 );
420 }
421
422 #[test]
423 fn parse_entry_rejects_junk() {
424 for junk in [
425 "not-an-ip",
426 "",
427 "203.0.113.0/33",
428 "203.0.113.7/",
429 "/24",
430 "::1/129",
431 ] {
432 assert_eq!(parse_entry(junk), None, "{junk} must not parse");
433 }
434 }
435
436 #[test]
437 fn family_never_crosses() {
438 let net = IpNet::parse("203.0.113.0/24").expect("v4 network");
439 assert!(net.contains(ip("203.0.113.7")));
440 assert!(!net.contains(ip("::1")));
444 let net = IpNet::parse("2001:db8::/32").expect("v6 network");
445 assert!(net.contains(ip("2001:db8::1")));
446 assert!(!net.contains(ip("203.0.113.7")));
447 }
448
449 #[test]
450 fn zero_prefix_networks_match_everything_in_family() {
451 let net = IpNet::parse("0.0.0.0/0").expect("v4 /0");
452 assert!(net.contains(ip("203.0.113.7")));
453 assert!(!net.contains(ip("2001:db8::1")));
454 let net = IpNet::parse("::/0").expect("v6 /0");
455 assert!(net.contains(ip("2001:db8::1")));
456 assert!(!net.contains(ip("203.0.113.7")));
457 }
458
459 #[test]
460 fn new_reports_the_list_and_entry_of_the_first_reject() {
461 let error = IpGateConfig::new(["203.0.113.7"], ["ok-but-not-an-ip"], NIL).unwrap_err();
462 assert_eq!(error.list, "blacklist");
463 assert_eq!(error.entry, "ok-but-not-an-ip");
464 assert_eq!(
465 error.to_string(),
466 "invalid blacklist entry 'ok-but-not-an-ip': expected an IP address or CIDR range"
467 );
468
469 let error = IpGateConfig::new(NIL, NIL, ["203.0.113.0/33"]).unwrap_err();
470 assert_eq!(error.list, "exempt_ips");
471
472 let error = IpGateConfig::new(["::1/129"], NIL, NIL).unwrap_err();
473 assert_eq!(error.list, "whitelist");
474 }
475
476 #[test]
477 fn new_accepts_empty_lists_and_the_default_is_inert() {
478 let gate = IpGateConfig::new(NIL, NIL, NIL).expect("empty lists");
479 for address in ["203.0.113.7", "2001:db8::1", "::ffff:203.0.113.7"] {
480 assert_eq!(
481 gate.evaluate(ip(address)),
482 IpGateVerdict::Allowed(IpGateDecision::default())
483 );
484 }
485 assert_eq!(
486 IpGateConfig::default().evaluate(ip("192.0.2.1")),
487 IpGateVerdict::Allowed(IpGateDecision::default())
488 );
489 }
490
491 #[test]
492 fn blacklist_denies_and_exemption_does_not_win() {
493 let gate =
494 IpGateConfig::new(NIL, ["203.0.113.9", "198.51.100.0/24"], ["203.0.113.9"]).unwrap();
495 assert_eq!(
496 gate.evaluate(ip("203.0.113.9")),
497 IpGateVerdict::Denied(IpGateDenial::Blacklisted)
498 );
499 assert_eq!(
500 gate.evaluate(ip("198.51.100.77")),
501 IpGateVerdict::Denied(IpGateDenial::Blacklisted)
502 );
503 assert_eq!(
504 gate.evaluate(ip("203.0.113.9")).unwrap_denied().reason(),
505 "IP is blacklisted"
506 );
507 assert!(matches!(
509 gate.evaluate(ip("198.51.200.1")),
510 IpGateVerdict::Allowed(_)
511 ));
512 }
513
514 #[test]
515 fn exemption_never_opens_the_whitelist_gate() {
516 let gate = IpGateConfig::new(["192.0.2.1"], NIL, ["203.0.113.7"]).unwrap();
517 assert_eq!(
518 gate.evaluate(ip("203.0.113.7")),
519 IpGateVerdict::Denied(IpGateDenial::NotInWhitelist)
520 );
521 assert_eq!(
522 gate.evaluate(ip("203.0.113.7")).unwrap_denied().reason(),
523 "IP not in whitelist"
524 );
525 assert!(matches!(
526 gate.evaluate(ip("192.0.2.1")),
527 IpGateVerdict::Allowed(IpGateDecision {
528 is_whitelisted: true,
529 is_exempt: false
530 })
531 ));
532 }
533
534 #[test]
535 fn an_ip_on_both_lists_is_simply_a_whitelist_match() {
536 let gate = IpGateConfig::new(["203.0.113.7"], NIL, ["203.0.113.7"]).unwrap();
537 assert_eq!(
538 gate.evaluate(ip("203.0.113.7")),
539 IpGateVerdict::Allowed(IpGateDecision {
540 is_whitelisted: true,
541 is_exempt: true
542 })
543 );
544 }
545
546 #[test]
547 fn exempt_exact_and_cidr_matches_set_the_flag_without_deny_paths() {
548 let gate = IpGateConfig::new(NIL, NIL, ["198.51.100.7", "198.51.100.16/28"]).unwrap();
549 for address in ["198.51.100.7", "198.51.100.20", "::ffff:198.51.100.7"] {
550 assert_eq!(
551 gate.evaluate(ip(address)),
552 IpGateVerdict::Allowed(IpGateDecision {
553 is_whitelisted: false,
554 is_exempt: true
555 }),
556 "{address} must be exempt"
557 );
558 }
559 assert_eq!(
560 gate.evaluate(ip("198.51.100.15")),
561 IpGateVerdict::Allowed(IpGateDecision::default()),
562 "the address just below the CIDR range is not exempt"
563 );
564 }
565
566 #[test]
567 fn ipv4_mapped_parity_across_all_list_forms() {
568 let gate = IpGateConfig::new(["::ffff:203.0.113.7"], NIL, NIL).unwrap();
570 assert!(matches!(
571 gate.evaluate(ip("203.0.113.7")),
572 IpGateVerdict::Allowed(IpGateDecision {
573 is_whitelisted: true,
574 ..
575 })
576 ));
577 let gate = IpGateConfig::new(["203.0.113.0/24"], NIL, NIL).unwrap();
579 assert!(matches!(
580 gate.evaluate(ip("::ffff:203.0.113.99")),
581 IpGateVerdict::Allowed(IpGateDecision {
582 is_whitelisted: true,
583 ..
584 })
585 ));
586 let gate = IpGateConfig::new(["::ffff:0:0/96"], NIL, NIL).unwrap();
588 assert!(matches!(
589 gate.evaluate(ip("::ffff:203.0.113.99")),
590 IpGateVerdict::Allowed(IpGateDecision {
591 is_whitelisted: true,
592 ..
593 })
594 ));
595 assert!(!matches!(
596 gate.evaluate(ip("2001:db8::1")),
597 IpGateVerdict::Allowed(IpGateDecision {
598 is_whitelisted: true,
599 ..
600 })
601 ));
602 }
603
604 #[test]
605 fn exempt_flag_is_only_set_after_the_deny_checks_pass() {
606 let gate = IpGateConfig::new(NIL, ["198.51.100.7"], ["198.51.100.7"]).unwrap();
607 match gate.evaluate(ip("198.51.100.7")) {
608 IpGateVerdict::Denied(denial) => assert_eq!(denial, IpGateDenial::Blacklisted),
609 IpGateVerdict::Allowed(_) => panic!("a blacklisted exempt IP must be denied"),
610 }
611 }
612
613 impl IpGateVerdict {
614 fn unwrap_denied(self) -> IpGateDenial {
616 match self {
617 Self::Allowed(_) => panic!("expected a denial"),
618 Self::Denied(denial) => denial,
619 }
620 }
621 }
622}