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}
262
263/// The provider ranges the block check consults.
264///
265/// The default table is empty: every provider is unloaded and
266/// [`CloudIpTable::is_cloud_ip`] blocks nothing until the adapter feeds
267/// ranges. Clones share the store (the reference module-singleton
268/// semantics), so a background refresher can swap ranges under an
269/// installed stage.
270#[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    /// Load (or replace) one provider's ranges: `(cidr, region)` pairs,
283    /// the region absent for providers that publish none.
284    ///
285    /// # Errors
286    ///
287    /// Returns `(cidr, error)` for the first entry that is neither a CIDR
288    /// range nor a bare address - the reference fetchers only produce
289    /// valid networks, so this port fails loudly instead of silently
290    /// dropping an entry.
291    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    /// Drop one provider's ranges (the reference's failed-refresh shape:
314    /// an unloaded provider is skipped, not blocked).
315    pub fn clear_provider(&self, provider: &str) {
316        self.inner
317            .write()
318            .expect("cloud provider table")
319            .remove(provider);
320    }
321
322    /// Whether a provider has any ranges loaded (the reference
323    /// `get_status`'s `ready` flag).
324    #[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    /// The reference `is_cloud_ip`: whether `ip` falls inside a blocked
334    /// provider's network, honoring the region carve-outs.
335    // The read guard must outlive the whole network walk (every iteration
336    // reads through the `ranges` reference it produced); the nursery lint
337    // cannot see that and wants it dropped per provider. One acquisition
338    // for the whole walk is the point.
339    #[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    /// The reference `get_cloud_provider_details`: the first
364    /// `(provider, network)` pair (in selector order) whose ranges contain
365    /// `ip`.
366    #[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/// An invalid range entry: the error [`CloudIpTable::set_provider_ranges`]
387/// fails with.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct CloudRangeError {
390    /// The rejected entry.
391    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        // Provider names are exact: no case folding, like the reference set.
443        assert!(parse_cloud_selectors(["aws"]).is_err());
444        // An empty carve-out region is a bare block, not an error.
445        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        // A bare address is a single-host network.
455        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        // The unloaded GCP ranges block nothing; the address outside AWS
507        // passes.
508        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        // A provider without region data blocks everything under a
545        // carve-out: an unknown region can never match.
546        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        // The provider stays unloaded after the failure.
565        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}