Skip to main content

guard_core_engine/
cloud_provider.rs

1//! The cloud-provider IP table and block selectors.
2//!
3//! This is the Rust family's port of the reference engine's cloud-provider
4//! blocking data path (`guard_core/handlers/cloud_handler.py` plus the
5//! selector registry in `guard_core/handlers/_cloud_provider_registry.py`
6//! and the config validation in `guard_core/models.py`). It carries three
7//! pieces:
8//!
9//! - [`parse_cloud_selectors`]: the `block_cloud_providers` syntax. A bare
10//!   name (`"GCP"`) blocks the whole provider; a `":!region"` suffix
11//!   (`"GCP:!us-central1"`) blocks the provider **except** that region. An
12//!   unrecognized provider name is a config error, exactly the reference
13//!   `_validate_block_cloud_providers_value` `ValueError`.
14//! - [`CloudIpTable`]: the provider ranges the block check consults. The
15//!   reference keeps a module-singleton handler fed by background fetchers
16//!   (with Redis caching and a refresh interval); those are I/O concerns
17//!   this engine does not own, so the table is an in-memory store the
18//!   adapter feeds (`set_provider_ranges`), shareable across clones.
19//! - [`CloudIpTable::is_cloud_ip`]: the reference `is_cloud_ip` walk - for
20//!   every selected provider that has ranges loaded, an address inside one
21//!   of its networks is blocked unless the network's region is carved out.
22//!
23//! ## Details that mirror the reference exactly
24//!
25//! - Region carve-outs only exempt networks whose region is **known** (the
26//!   reference `network_regions.get(str(network)) in allowed_regions`):
27//!   a provider that publishes no region data (`Azure`, `DigitalOcean`,
28//!   `Linode`, `Vultr`) blocks its whole provider even under a carve-out.
29//! - Providers with no ranges loaded are skipped, not blocked (the
30//!   reference `if provider not in self.ip_ranges: continue`), and an
31//!   empty range set for a loaded provider blocks nothing (the reference
32//!   only logs a warning).
33//! - Family-strict matching: an IPv4 network never contains an IPv6
34//!   address and vice versa, like `ipaddress` membership.
35//!
36//! # Example
37//!
38//! ```
39//! use guard_core_engine::cloud_provider::{parse_cloud_selectors, CloudIpTable};
40//! use std::net::IpAddr;
41//! use std::str::FromStr;
42//!
43//! // Block GCP except one region, and all of AWS.
44//! let selectors = parse_cloud_selectors(["GCP:!us-central1", "AWS"]).expect("valid selectors");
45//! let table = CloudIpTable::default();
46//! table
47//!     .set_provider_ranges(
48//!         "AWS",
49//!         vec![("203.0.113.0/24".to_owned(), None)],
50//!     )
51//!     .expect("valid ranges");
52//! table
53//!     .set_provider_ranges(
54//!         "GCP",
55//!         vec![
56//!             ("198.51.100.0/24".to_owned(), Some("us-central1".to_owned())),
57//!             ("198.51.101.0/24".to_owned(), Some("europe-west1".to_owned())),
58//!         ],
59//!     )
60//!     .expect("valid ranges");
61//!
62//! // An AWS address is blocked ...
63//! assert!(table.is_cloud_ip(IpAddr::from_str("203.0.113.9").unwrap(), &selectors));
64//! // ... a GCP address in the carved-out region is not ...
65//! assert!(!table.is_cloud_ip(IpAddr::from_str("198.51.100.9").unwrap(), &selectors));
66//! // ... a GCP address elsewhere is ...
67//! assert!(table.is_cloud_ip(IpAddr::from_str("198.51.101.9").unwrap(), &selectors));
68//! // ... and a datacenter address belonging to nobody passes.
69//! assert!(!table.is_cloud_ip(IpAddr::from_str("192.0.2.1").unwrap(), &selectors));
70//! ```
71
72use std::collections::HashMap;
73use std::fmt;
74use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
75use std::str::FromStr;
76use std::sync::{Arc, RwLock};
77
78/// The providers the reference publishes range data for
79/// (`VALID_CLOUD_PROVIDERS` / `_ALL_PROVIDERS`).
80pub const VALID_CLOUD_PROVIDERS: [&str; 6] =
81    ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"];
82
83/// The selector separator carving a region exception out of a block
84/// (`":!"` in the reference `_parse_cloud_selectors`).
85pub const CARVE_OUT_SEPARATOR: &str = ":!";
86
87/// An invalid `block_cloud_providers` entry: the config error
88/// [`parse_cloud_selectors`] fails closed with.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CloudConfigError {
91    /// The rejected selector.
92    pub selector: String,
93    /// The provider name the selector named (its text before the carve-out
94    /// separator).
95    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/// The parsed `block_cloud_providers` selectors: the blocked provider
116/// names in first-seen order, each optionally carrying carved-out regions.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct CloudSelectors {
119    /// The blocked provider names (bare, without the carve-out suffix).
120    pub blocked: Vec<String>,
121    /// Provider -> the regions its block does NOT apply to.
122    pub carveouts: HashMap<String, Vec<String>>,
123}
124
125/// Parse the `block_cloud_providers` selectors, failing closed on an
126/// unknown provider name.
127///
128/// The selector provider must be one of [`VALID_CLOUD_PROVIDERS`], the
129/// reference `_validate_block_cloud_providers_value` `ValueError`.
130///
131/// # Errors
132///
133/// [`CloudConfigError`] naming the first selector with an unknown provider.
134pub 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
162/// Split one selector into `(provider, had_marker, region)` - the reference
163/// `selector.partition(":!")`.
164fn 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/// One parsed network entry: a CIDR range (or a bare address) with the host
172/// bits cleared, family-strict membership.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct Network {
175    addr: IpAddr,
176    prefix: u8,
177}
178
179impl Network {
180    /// Parse an `addr/prefix` entry (or a bare address), clearing the host
181    /// bits - `ip_network(entry, strict=False)` in the reference.
182    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    /// Family-strict membership: a v4 network never contains a v6 address.
202    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    /// The canonical `addr/prefix` form (`str(ip_network(...))` in the
217    /// reference, host bits cleared).
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "{}/{}", self.addr, self.prefix)
220    }
221}
222
223/// The address family's bit width.
224const fn family_bits(addr: IpAddr) -> u8 {
225    match addr {
226        IpAddr::V4(_) => 32,
227        IpAddr::V6(_) => 128,
228    }
229}
230
231/// Clear the host bits of `addr` below `prefix`.
232fn 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/// One provider's loaded ranges plus the region each network belongs to
256/// (absent for providers that publish no region data).
257#[derive(Debug, Clone, Default)]
258struct ProviderRanges {
259    networks: Vec<Network>,
260    regions: HashMap<String, String>,
261    /// The unix-second stamp of the last `set_provider_ranges` write (the
262    /// reference `last_updated` entry the status payload serves;
263    /// `None` until the first load).
264    last_refreshed: Option<i64>,
265}
266
267/// One cloud provider's row of the status payload (the reference
268/// `cloud_handler.get_status` shape).
269///
270/// Whether ranges are loaded, the unix-second stamp of the last load, and
271/// how many networks the provider carries.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct ProviderStatus {
274    /// Whether any ranges are loaded (`ready`).
275    pub ready: bool,
276    /// The unix-second stamp of the last range load (`last_refreshed`;
277    /// `None` for a provider that never loaded, the reference's `null`).
278    pub last_refreshed: Option<i64>,
279    /// How many networks the provider carries (`entries`).
280    pub entries: usize,
281}
282
283/// The provider ranges the block check consults.
284///
285/// The default table is empty: every provider is unloaded and
286/// [`CloudIpTable::is_cloud_ip`] blocks nothing until the adapter feeds
287/// ranges. Clones share the store (the reference module-singleton
288/// semantics), so a background refresher can swap ranges under an
289/// installed stage.
290#[derive(Clone, Default)]
291pub struct CloudIpTable {
292    inner: Arc<RwLock<HashMap<String, ProviderRanges>>>,
293}
294
295impl fmt::Debug for CloudIpTable {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        f.debug_struct("CloudIpTable").finish_non_exhaustive()
298    }
299}
300
301impl CloudIpTable {
302    /// Load (or replace) one provider's ranges: `(cidr, region)` pairs,
303    /// the region absent for providers that publish none.
304    ///
305    /// # Errors
306    ///
307    /// Returns `(cidr, error)` for the first entry that is neither a CIDR
308    /// range nor a bare address - the reference fetchers only produce
309    /// valid networks, so this port fails loudly instead of silently
310    /// dropping an entry.
311    pub fn set_provider_ranges(
312        &self,
313        provider: &str,
314        entries: Vec<(String, Option<String>)>,
315    ) -> Result<(), (String, CloudRangeError)> {
316        let mut networks = Vec::with_capacity(entries.len());
317        let mut regions = HashMap::new();
318        for (cidr, region) in entries {
319            let network =
320                Network::parse(&cidr).ok_or_else(|| (cidr.clone(), CloudRangeError { cidr }))?;
321            if let Some(region) = region {
322                regions.insert(network.to_string(), region);
323            }
324            networks.push(network);
325        }
326        let now = std::time::SystemTime::now()
327            .duration_since(std::time::UNIX_EPOCH)
328            .map_or(0, |elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(0));
329        self.inner.write().expect("cloud provider table").insert(
330            provider.to_owned(),
331            ProviderRanges {
332                networks,
333                regions,
334                last_refreshed: Some(now),
335            },
336        );
337        Ok(())
338    }
339
340    /// Drop one provider's ranges (the reference's failed-refresh shape:
341    /// an unloaded provider is skipped, not blocked).
342    pub fn clear_provider(&self, provider: &str) {
343        self.inner
344            .write()
345            .expect("cloud provider table")
346            .remove(provider);
347    }
348
349    /// Whether a provider has any ranges loaded (the reference
350    /// `get_status`'s `ready` flag).
351    #[must_use]
352    pub fn provider_is_ready(&self, provider: &str) -> bool {
353        self.inner
354            .read()
355            .expect("cloud provider table")
356            .get(provider)
357            .is_some_and(|ranges| !ranges.networks.is_empty())
358    }
359
360    /// One provider's status row (the reference `get_status` payload
361    /// shape): `ready`, the `last_refreshed` stamp (`None` = the
362    /// reference's `null`), and the `entries` count. An unloaded provider
363    /// answers `ready:false`, `None`, `0`.
364    #[must_use]
365    pub fn provider_status(&self, provider: &str) -> ProviderStatus {
366        self.inner
367            .read()
368            .expect("cloud provider table")
369            .get(provider)
370            .map_or(
371                ProviderStatus {
372                    ready: false,
373                    last_refreshed: None,
374                    entries: 0,
375                },
376                |ranges| ProviderStatus {
377                    ready: !ranges.networks.is_empty(),
378                    last_refreshed: ranges.last_refreshed,
379                    entries: ranges.networks.len(),
380                },
381            )
382    }
383
384    /// The reference `is_cloud_ip`: whether `ip` falls inside a blocked
385    /// provider's network, honoring the region carve-outs.
386    // The read guard must outlive the whole network walk (every iteration
387    // reads through the `ranges` reference it produced); the nursery lint
388    // cannot see that and wants it dropped per provider. One acquisition
389    // for the whole walk is the point.
390    #[allow(clippy::significant_drop_tightening)]
391    #[must_use]
392    pub fn is_cloud_ip(&self, ip: IpAddr, selectors: &CloudSelectors) -> bool {
393        let table = self.inner.read().expect("cloud provider table");
394        selectors.blocked.iter().any(|provider| {
395            let Some(ranges) = table.get(provider) else {
396                return false;
397            };
398            let allowed_regions = selectors.carveouts.get(provider);
399            ranges.networks.iter().any(|network| {
400                if !network.contains(ip) {
401                    return false;
402                }
403                let carved_out = allowed_regions.is_some_and(|allowed| {
404                    ranges
405                        .regions
406                        .get(&network.to_string())
407                        .is_some_and(|region| allowed.iter().any(|name| name == region))
408                });
409                !carved_out
410            })
411        })
412    }
413
414    /// The reference `get_cloud_provider_details`: the first
415    /// `(provider, network)` pair (in selector order) whose ranges contain
416    /// `ip`.
417    #[must_use]
418    pub fn provider_details(
419        &self,
420        ip: IpAddr,
421        selectors: &CloudSelectors,
422    ) -> Option<(String, String)> {
423        let table = self.inner.read().expect("cloud provider table");
424        for provider in &selectors.blocked {
425            if let Some(ranges) = table.get(provider) {
426                for network in &ranges.networks {
427                    if network.contains(ip) {
428                        return Some((provider.clone(), network.to_string()));
429                    }
430                }
431            }
432        }
433        None
434    }
435}
436
437/// An invalid range entry: the error [`CloudIpTable::set_provider_ranges`]
438/// fails with.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct CloudRangeError {
441    /// The rejected entry.
442    pub cidr: String,
443}
444
445impl fmt::Display for CloudRangeError {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        write!(
448            f,
449            "invalid cloud provider range '{}': expected an IP address or CIDR range",
450            self.cidr
451        )
452    }
453}
454
455impl std::error::Error for CloudRangeError {}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    fn ip(text: &str) -> IpAddr {
462        IpAddr::from_str(text).expect("test address")
463    }
464
465    #[test]
466    fn selectors_parse_bare_and_carve_out_forms() {
467        let selectors = parse_cloud_selectors(["GCP:!us-central1", "AWS", "GCP:!europe-west1"])
468            .expect("valid selectors");
469        assert_eq!(selectors.blocked, vec!["GCP".to_owned(), "AWS".to_owned()]);
470        assert_eq!(
471            selectors.carveouts.get("GCP").expect("carveouts"),
472            &vec!["us-central1".to_owned(), "europe-west1".to_owned()]
473        );
474        assert!(!selectors.carveouts.contains_key("AWS"));
475
476        let selectors = parse_cloud_selectors(["Linode"]).expect("valid selectors");
477        assert_eq!(selectors.blocked, vec!["Linode".to_owned()]);
478        assert!(selectors.carveouts.is_empty());
479    }
480
481    #[test]
482    fn unknown_provider_fails_closed_with_the_reference_message() {
483        let error = parse_cloud_selectors(["GCP", "Hetzner"]).unwrap_err();
484        assert_eq!(error.selector, "Hetzner");
485        assert_eq!(error.provider, "Hetzner");
486        assert_eq!(
487            error.to_string(),
488            "Unknown cloud providers in block_cloud_providers: [Hetzner]. \
489             Valid: [AWS, Azure, DigitalOcean, GCP, Linode, Vultr] \
490             (a bare name blocks the whole provider; \
491             suffix ':!region' to carve out a region exception)"
492        );
493        // Provider names are exact: no case folding, like the reference set.
494        assert!(parse_cloud_selectors(["aws"]).is_err());
495        // An empty carve-out region is a bare block, not an error.
496        let selectors = parse_cloud_selectors(["GCP:!"]).expect("valid selectors");
497        assert_eq!(selectors.blocked, vec!["GCP".to_owned()]);
498        assert!(!selectors.carveouts.contains_key("GCP"));
499    }
500
501    #[test]
502    fn network_parse_clears_host_bits_and_display_is_canonical() {
503        let network = Network::parse("203.0.113.77/24").expect("parses");
504        assert_eq!(network.to_string(), "203.0.113.0/24");
505        // A bare address is a single-host network.
506        let network = Network::parse("203.0.113.7").expect("parses");
507        assert_eq!(network.to_string(), "203.0.113.7/32");
508        let network = Network::parse("2001:db8::5").expect("parses");
509        assert_eq!(network.to_string(), "2001:db8::5/128");
510    }
511
512    #[test]
513    fn network_parse_rejects_junk() {
514        for junk in ["not-an-ip", "203.0.113.0/33", "203.0.113.7/", "/24", ""] {
515            assert!(Network::parse(junk).is_none(), "{junk} must not parse");
516        }
517    }
518
519    #[test]
520    fn membership_is_family_strict() {
521        let network = Network::parse("203.0.113.0/24").expect("v4");
522        assert!(network.contains(ip("203.0.113.9")));
523        assert!(!network.contains(ip("2001:db8::1")));
524        let network = Network::parse("2001:db8::/32").expect("v6");
525        assert!(network.contains(ip("2001:db8::9")));
526        assert!(!network.contains(ip("203.0.113.9")));
527    }
528
529    #[test]
530    fn an_empty_table_blocks_nothing() {
531        let selectors = parse_cloud_selectors(VALID_CLOUD_PROVIDERS).expect("valid selectors");
532        let table = CloudIpTable::default();
533        assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
534        assert!(!table.provider_is_ready("AWS"));
535    }
536
537    #[test]
538    fn provider_status_answers_the_reference_row_shape() {
539        let table = CloudIpTable::default();
540        // Unloaded: the reference's ready:false / null / 0 row.
541        assert_eq!(
542            table.provider_status("AWS"),
543            ProviderStatus {
544                ready: false,
545                last_refreshed: None,
546                entries: 0,
547            }
548        );
549
550        // Loaded: ready, stamped, and the entry count.
551        table
552            .set_provider_ranges(
553                "AWS",
554                vec![
555                    ("203.0.113.0/24".to_owned(), None),
556                    ("198.51.100.0/24".to_owned(), Some("us-east-1".to_owned())),
557                ],
558            )
559            .expect("valid ranges");
560        let row = table.provider_status("AWS");
561        assert!(row.ready);
562        assert!(row.last_refreshed.is_some(), "the load stamps the row");
563        assert_eq!(row.entries, 2);
564
565        // A replacement reloads the stamp (the refresh's swap shape).
566        std::thread::sleep(std::time::Duration::from_millis(1100));
567        table
568            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
569            .expect("valid ranges");
570        let reloaded = table.provider_status("AWS");
571        assert!(
572            reloaded.last_refreshed > row.last_refreshed,
573            "the reload stamps forward"
574        );
575        assert_eq!(reloaded.entries, 1);
576
577        // A cleared provider answers the unloaded row again.
578        table.clear_provider("AWS");
579        assert_eq!(
580            table.provider_status("AWS"),
581            ProviderStatus {
582                ready: false,
583                last_refreshed: None,
584                entries: 0,
585            }
586        );
587    }
588
589    #[test]
590    fn loaded_provider_blocks_its_ranges() {
591        let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
592        let table = CloudIpTable::default();
593        table
594            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
595            .expect("valid ranges");
596        assert!(table.provider_is_ready("AWS"));
597        assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
598        assert!(!table.is_cloud_ip(ip("203.0.114.9"), &selectors));
599        assert!(!table.is_cloud_ip(ip("2001:db8::9"), &selectors));
600    }
601
602    #[test]
603    fn an_unloaded_selected_provider_is_skipped_not_blocking() {
604        let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
605        let table = CloudIpTable::default();
606        table
607            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
608            .expect("valid ranges");
609        // The unloaded GCP ranges block nothing; the address outside AWS
610        // passes.
611        assert!(!table.is_cloud_ip(ip("192.0.2.9"), &selectors));
612        assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
613    }
614
615    #[test]
616    fn a_cleared_provider_stops_blocking() {
617        let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
618        let table = CloudIpTable::default();
619        table
620            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
621            .expect("valid ranges");
622        assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
623        table.clear_provider("AWS");
624        assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
625        assert!(!table.provider_is_ready("AWS"));
626    }
627
628    #[test]
629    fn carve_outs_exempt_only_networks_with_a_known_region() {
630        // A repeated carve-out region is deduplicated in place.
631        let selectors =
632            parse_cloud_selectors(["GCP:!us-central1", "GCP:!us-central1"]).expect("valid");
633        assert_eq!(
634            selectors.carveouts.get("GCP").expect("carveouts"),
635            &vec![String::from("us-central1")],
636            "the same carve-out region is not recorded twice"
637        );
638        let table = CloudIpTable::default();
639        table
640            .set_provider_ranges(
641                "GCP",
642                vec![
643                    ("198.51.100.0/24".to_owned(), Some("us-central1".to_owned())),
644                    (
645                        "198.51.101.0/24".to_owned(),
646                        Some("europe-west1".to_owned()),
647                    ),
648                ],
649            )
650            .expect("valid ranges");
651        assert!(!table.is_cloud_ip(ip("198.51.100.9"), &selectors));
652        assert!(table.is_cloud_ip(ip("198.51.101.9"), &selectors));
653
654        // A provider without region data blocks everything under a
655        // carve-out: an unknown region can never match.
656        let selectors = parse_cloud_selectors(["Azure:!somewhere"]).expect("valid selectors");
657        table
658            .set_provider_ranges("Azure", vec![("203.0.113.0/24".to_owned(), None)])
659            .expect("valid ranges");
660        assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
661    }
662
663    #[test]
664    fn set_provider_ranges_fails_closed_on_a_junk_entry() {
665        let table = CloudIpTable::default();
666        let error = table
667            .set_provider_ranges("AWS", vec![("not-a-range".to_owned(), None)])
668            .unwrap_err();
669        assert_eq!(error.0, "not-a-range");
670        assert_eq!(
671            error.1.to_string(),
672            "invalid cloud provider range 'not-a-range': expected an IP address or CIDR range"
673        );
674        // The provider stays unloaded after the failure.
675        let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
676        assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
677    }
678
679    #[test]
680    fn details_report_the_first_matching_selector_provider() {
681        let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
682        let table = CloudIpTable::default();
683        table
684            .set_provider_ranges("GCP", vec![("198.51.100.0/24".to_owned(), None)])
685            .expect("valid ranges");
686        table
687            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
688            .expect("valid ranges");
689        assert_eq!(
690            table.provider_details(ip("203.0.113.9"), &selectors),
691            Some(("AWS".to_owned(), "203.0.113.0/24".to_owned()))
692        );
693        assert_eq!(
694            table.provider_details(ip("198.51.100.9"), &selectors),
695            Some(("GCP".to_owned(), "198.51.100.0/24".to_owned()))
696        );
697        assert_eq!(table.provider_details(ip("192.0.2.9"), &selectors), None);
698    }
699
700    #[test]
701    fn clones_share_the_table() {
702        let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
703        let table = CloudIpTable::default();
704        let clone = table.clone();
705        table
706            .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
707            .expect("valid ranges");
708        assert!(clone.is_cloud_ip(ip("203.0.113.9"), &selectors));
709    }
710
711    #[test]
712    fn masks_handle_the_zero_prefixes_and_render_debug() {
713        assert_eq!(masked(ip("192.0.2.9"), 0), ip("0.0.0.0"));
714        assert_eq!(masked(ip("192.0.2.9"), 32), ip("192.0.2.9"));
715        assert_eq!(
716            masked(IpAddr::V6("2001:db8::1".parse().expect("v6")), 0),
717            ip("::")
718        );
719        let table = CloudIpTable::default();
720        assert!(format!("{table:?}").contains("CloudIpTable"));
721    }
722}
723
724#[cfg(test)]
725mod gap_tests {
726    use super::*;
727
728    #[test]
729    fn a_provider_without_fed_ranges_has_no_details_and_blocks_nothing() {
730        // the selectors parse (AWS is a known provider) but the table was
731        // never fed its ranges
732        let table = CloudIpTable::default();
733        let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
734        let probe = "203.0.113.9".parse().expect("ip");
735        assert!(table.provider_details(probe, &selectors).is_none());
736        assert!(!table.is_cloud_ip(probe, &selectors));
737    }
738}