1use std::collections::HashMap;
73use std::fmt;
74use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
75use std::str::FromStr;
76use std::sync::{Arc, RwLock};
77
78pub const VALID_CLOUD_PROVIDERS: [&str; 6] =
81 ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"];
82
83pub const CARVE_OUT_SEPARATOR: &str = ":!";
86
87#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CloudConfigError {
91 pub selector: String,
93 pub provider: String,
96}
97
98impl fmt::Display for CloudConfigError {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 let mut valid: Vec<&str> = VALID_CLOUD_PROVIDERS.to_vec();
101 valid.sort_unstable();
102 write!(
103 f,
104 "Unknown cloud providers in block_cloud_providers: [{}]. \
105 Valid: [{}] (a bare name blocks the whole provider; \
106 suffix ':!region' to carve out a region exception)",
107 self.selector,
108 valid.join(", ")
109 )
110 }
111}
112
113impl std::error::Error for CloudConfigError {}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct CloudSelectors {
119 pub blocked: Vec<String>,
121 pub carveouts: HashMap<String, Vec<String>>,
123}
124
125pub fn parse_cloud_selectors<I, S>(selectors: I) -> Result<CloudSelectors, CloudConfigError>
135where
136 I: IntoIterator<Item = S>,
137 S: AsRef<str>,
138{
139 let mut parsed = CloudSelectors::default();
140 for selector in selectors {
141 let selector = selector.as_ref();
142 let (provider, marker, region) = partition_carve_out(selector);
143 if !VALID_CLOUD_PROVIDERS.contains(&provider) {
144 return Err(CloudConfigError {
145 selector: selector.to_owned(),
146 provider: provider.to_owned(),
147 });
148 }
149 if !parsed.blocked.iter().any(|name| name == provider) {
150 parsed.blocked.push(provider.to_owned());
151 }
152 if marker && !region.is_empty() {
153 let regions = parsed.carveouts.entry(provider.to_owned()).or_default();
154 if !regions.iter().any(|known| known == region) {
155 regions.push(region.to_owned());
156 }
157 }
158 }
159 Ok(parsed)
160}
161
162fn partition_carve_out(selector: &str) -> (&str, bool, &str) {
165 match selector.split_once(CARVE_OUT_SEPARATOR) {
166 Some((provider, region)) => (provider, true, region),
167 None => (selector, false, ""),
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct Network {
175 addr: IpAddr,
176 prefix: u8,
177}
178
179impl Network {
180 fn parse(text: &str) -> Option<Self> {
183 if let Some((addr_part, prefix_part)) = text.split_once('/') {
184 let addr = IpAddr::from_str(addr_part).ok()?;
185 let prefix = u8::from_str(prefix_part).ok()?;
186 if prefix > family_bits(addr) {
187 return None;
188 }
189 return Some(Self {
190 addr: masked(addr, prefix),
191 prefix,
192 });
193 }
194 let addr = IpAddr::from_str(text).ok()?;
195 Some(Self {
196 addr,
197 prefix: family_bits(addr),
198 })
199 }
200
201 fn contains(&self, addr: IpAddr) -> bool {
203 match (self.addr, addr) {
204 (IpAddr::V4(net), IpAddr::V4(ip)) => {
205 u32::from(net) == (u32::from(ip) & v4_mask(self.prefix))
206 }
207 (IpAddr::V6(net), IpAddr::V6(ip)) => {
208 u128::from(net) == (u128::from(ip) & v6_mask(self.prefix))
209 }
210 _ => false,
211 }
212 }
213}
214
215impl fmt::Display for Network {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 write!(f, "{}/{}", self.addr, self.prefix)
220 }
221}
222
223const fn family_bits(addr: IpAddr) -> u8 {
225 match addr {
226 IpAddr::V4(_) => 32,
227 IpAddr::V6(_) => 128,
228 }
229}
230
231fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
233 match addr {
234 IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
235 IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
236 }
237}
238
239const fn v4_mask(prefix: u8) -> u32 {
240 if prefix == 0 {
241 0
242 } else {
243 u32::MAX << (32 - prefix)
244 }
245}
246
247const fn v6_mask(prefix: u8) -> u128 {
248 if prefix == 0 {
249 0
250 } else {
251 u128::MAX << (128 - prefix)
252 }
253}
254
255#[derive(Debug, Clone, Default)]
258struct ProviderRanges {
259 networks: Vec<Network>,
260 regions: HashMap<String, String>,
261}
262
263#[derive(Clone, Default)]
271pub struct CloudIpTable {
272 inner: Arc<RwLock<HashMap<String, ProviderRanges>>>,
273}
274
275impl fmt::Debug for CloudIpTable {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277 f.debug_struct("CloudIpTable").finish_non_exhaustive()
278 }
279}
280
281impl CloudIpTable {
282 pub fn set_provider_ranges(
292 &self,
293 provider: &str,
294 entries: Vec<(String, Option<String>)>,
295 ) -> Result<(), (String, CloudRangeError)> {
296 let mut networks = Vec::with_capacity(entries.len());
297 let mut regions = HashMap::new();
298 for (cidr, region) in entries {
299 let network =
300 Network::parse(&cidr).ok_or_else(|| (cidr.clone(), CloudRangeError { cidr }))?;
301 if let Some(region) = region {
302 regions.insert(network.to_string(), region);
303 }
304 networks.push(network);
305 }
306 self.inner
307 .write()
308 .expect("cloud provider table")
309 .insert(provider.to_owned(), ProviderRanges { networks, regions });
310 Ok(())
311 }
312
313 pub fn clear_provider(&self, provider: &str) {
316 self.inner
317 .write()
318 .expect("cloud provider table")
319 .remove(provider);
320 }
321
322 #[must_use]
325 pub fn provider_is_ready(&self, provider: &str) -> bool {
326 self.inner
327 .read()
328 .expect("cloud provider table")
329 .get(provider)
330 .is_some_and(|ranges| !ranges.networks.is_empty())
331 }
332
333 #[allow(clippy::significant_drop_tightening)]
340 #[must_use]
341 pub fn is_cloud_ip(&self, ip: IpAddr, selectors: &CloudSelectors) -> bool {
342 let table = self.inner.read().expect("cloud provider table");
343 selectors.blocked.iter().any(|provider| {
344 let Some(ranges) = table.get(provider) else {
345 return false;
346 };
347 let allowed_regions = selectors.carveouts.get(provider);
348 ranges.networks.iter().any(|network| {
349 if !network.contains(ip) {
350 return false;
351 }
352 let carved_out = allowed_regions.is_some_and(|allowed| {
353 ranges
354 .regions
355 .get(&network.to_string())
356 .is_some_and(|region| allowed.iter().any(|name| name == region))
357 });
358 !carved_out
359 })
360 })
361 }
362
363 #[must_use]
367 pub fn provider_details(
368 &self,
369 ip: IpAddr,
370 selectors: &CloudSelectors,
371 ) -> Option<(String, String)> {
372 let table = self.inner.read().expect("cloud provider table");
373 for provider in &selectors.blocked {
374 if let Some(ranges) = table.get(provider) {
375 for network in &ranges.networks {
376 if network.contains(ip) {
377 return Some((provider.clone(), network.to_string()));
378 }
379 }
380 }
381 }
382 None
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct CloudRangeError {
390 pub cidr: String,
392}
393
394impl fmt::Display for CloudRangeError {
395 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396 write!(
397 f,
398 "invalid cloud provider range '{}': expected an IP address or CIDR range",
399 self.cidr
400 )
401 }
402}
403
404impl std::error::Error for CloudRangeError {}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn ip(text: &str) -> IpAddr {
411 IpAddr::from_str(text).expect("test address")
412 }
413
414 #[test]
415 fn selectors_parse_bare_and_carve_out_forms() {
416 let selectors = parse_cloud_selectors(["GCP:!us-central1", "AWS", "GCP:!europe-west1"])
417 .expect("valid selectors");
418 assert_eq!(selectors.blocked, vec!["GCP".to_owned(), "AWS".to_owned()]);
419 assert_eq!(
420 selectors.carveouts.get("GCP").expect("carveouts"),
421 &vec!["us-central1".to_owned(), "europe-west1".to_owned()]
422 );
423 assert!(!selectors.carveouts.contains_key("AWS"));
424
425 let selectors = parse_cloud_selectors(["Linode"]).expect("valid selectors");
426 assert_eq!(selectors.blocked, vec!["Linode".to_owned()]);
427 assert!(selectors.carveouts.is_empty());
428 }
429
430 #[test]
431 fn unknown_provider_fails_closed_with_the_reference_message() {
432 let error = parse_cloud_selectors(["GCP", "Hetzner"]).unwrap_err();
433 assert_eq!(error.selector, "Hetzner");
434 assert_eq!(error.provider, "Hetzner");
435 assert_eq!(
436 error.to_string(),
437 "Unknown cloud providers in block_cloud_providers: [Hetzner]. \
438 Valid: [AWS, Azure, DigitalOcean, GCP, Linode, Vultr] \
439 (a bare name blocks the whole provider; \
440 suffix ':!region' to carve out a region exception)"
441 );
442 assert!(parse_cloud_selectors(["aws"]).is_err());
444 let selectors = parse_cloud_selectors(["GCP:!"]).expect("valid selectors");
446 assert_eq!(selectors.blocked, vec!["GCP".to_owned()]);
447 assert!(!selectors.carveouts.contains_key("GCP"));
448 }
449
450 #[test]
451 fn network_parse_clears_host_bits_and_display_is_canonical() {
452 let network = Network::parse("203.0.113.77/24").expect("parses");
453 assert_eq!(network.to_string(), "203.0.113.0/24");
454 let network = Network::parse("203.0.113.7").expect("parses");
456 assert_eq!(network.to_string(), "203.0.113.7/32");
457 let network = Network::parse("2001:db8::5").expect("parses");
458 assert_eq!(network.to_string(), "2001:db8::5/128");
459 }
460
461 #[test]
462 fn network_parse_rejects_junk() {
463 for junk in ["not-an-ip", "203.0.113.0/33", "203.0.113.7/", "/24", ""] {
464 assert!(Network::parse(junk).is_none(), "{junk} must not parse");
465 }
466 }
467
468 #[test]
469 fn membership_is_family_strict() {
470 let network = Network::parse("203.0.113.0/24").expect("v4");
471 assert!(network.contains(ip("203.0.113.9")));
472 assert!(!network.contains(ip("2001:db8::1")));
473 let network = Network::parse("2001:db8::/32").expect("v6");
474 assert!(network.contains(ip("2001:db8::9")));
475 assert!(!network.contains(ip("203.0.113.9")));
476 }
477
478 #[test]
479 fn an_empty_table_blocks_nothing() {
480 let selectors = parse_cloud_selectors(VALID_CLOUD_PROVIDERS).expect("valid selectors");
481 let table = CloudIpTable::default();
482 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
483 assert!(!table.provider_is_ready("AWS"));
484 }
485
486 #[test]
487 fn loaded_provider_blocks_its_ranges() {
488 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
489 let table = CloudIpTable::default();
490 table
491 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
492 .expect("valid ranges");
493 assert!(table.provider_is_ready("AWS"));
494 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
495 assert!(!table.is_cloud_ip(ip("203.0.114.9"), &selectors));
496 assert!(!table.is_cloud_ip(ip("2001:db8::9"), &selectors));
497 }
498
499 #[test]
500 fn an_unloaded_selected_provider_is_skipped_not_blocking() {
501 let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
502 let table = CloudIpTable::default();
503 table
504 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
505 .expect("valid ranges");
506 assert!(!table.is_cloud_ip(ip("192.0.2.9"), &selectors));
509 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
510 }
511
512 #[test]
513 fn a_cleared_provider_stops_blocking() {
514 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
515 let table = CloudIpTable::default();
516 table
517 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
518 .expect("valid ranges");
519 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
520 table.clear_provider("AWS");
521 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
522 assert!(!table.provider_is_ready("AWS"));
523 }
524
525 #[test]
526 fn carve_outs_exempt_only_networks_with_a_known_region() {
527 let selectors = parse_cloud_selectors(["GCP:!us-central1"]).expect("valid selectors");
528 let table = CloudIpTable::default();
529 table
530 .set_provider_ranges(
531 "GCP",
532 vec![
533 ("198.51.100.0/24".to_owned(), Some("us-central1".to_owned())),
534 (
535 "198.51.101.0/24".to_owned(),
536 Some("europe-west1".to_owned()),
537 ),
538 ],
539 )
540 .expect("valid ranges");
541 assert!(!table.is_cloud_ip(ip("198.51.100.9"), &selectors));
542 assert!(table.is_cloud_ip(ip("198.51.101.9"), &selectors));
543
544 let selectors = parse_cloud_selectors(["Azure:!somewhere"]).expect("valid selectors");
547 table
548 .set_provider_ranges("Azure", vec![("203.0.113.0/24".to_owned(), None)])
549 .expect("valid ranges");
550 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
551 }
552
553 #[test]
554 fn set_provider_ranges_fails_closed_on_a_junk_entry() {
555 let table = CloudIpTable::default();
556 let error = table
557 .set_provider_ranges("AWS", vec![("not-a-range".to_owned(), None)])
558 .unwrap_err();
559 assert_eq!(error.0, "not-a-range");
560 assert_eq!(
561 error.1.to_string(),
562 "invalid cloud provider range 'not-a-range': expected an IP address or CIDR range"
563 );
564 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
566 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
567 }
568
569 #[test]
570 fn details_report_the_first_matching_selector_provider() {
571 let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
572 let table = CloudIpTable::default();
573 table
574 .set_provider_ranges("GCP", vec![("198.51.100.0/24".to_owned(), None)])
575 .expect("valid ranges");
576 table
577 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
578 .expect("valid ranges");
579 assert_eq!(
580 table.provider_details(ip("203.0.113.9"), &selectors),
581 Some(("AWS".to_owned(), "203.0.113.0/24".to_owned()))
582 );
583 assert_eq!(
584 table.provider_details(ip("198.51.100.9"), &selectors),
585 Some(("GCP".to_owned(), "198.51.100.0/24".to_owned()))
586 );
587 assert_eq!(table.provider_details(ip("192.0.2.9"), &selectors), None);
588 }
589
590 #[test]
591 fn clones_share_the_table() {
592 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
593 let table = CloudIpTable::default();
594 let clone = table.clone();
595 table
596 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
597 .expect("valid ranges");
598 assert!(clone.is_cloud_ip(ip("203.0.113.9"), &selectors));
599 }
600}