Skip to main content

bgpkit_commons/rpki/
mod.rs

1//! RPKI (Resource Public Key Infrastructure) validation and data structures.
2//!
3//! This module provides functionality for loading and validating RPKI data from multiple sources,
4//! including real-time data from Cloudflare and historical data from RIPE NCC, RPKIviews, or RPKISPOOL.
5//!
6//! # Overview
7//!
8//! RPKI is a cryptographic framework used to secure internet routing by providing a way to
9//! validate the authenticity of BGP route announcements. This module implements RPKI validation
10//! using Route Origin Authorizations (ROAs) that specify which Autonomous Systems (ASes) are
11//! authorized to originate specific IP prefixes.
12//!
13//! # Data Sources
14//!
15//! ## Real-time Data (Cloudflare)
16//! - **Source**: [Cloudflare RPKI Portal](https://rpki.cloudflare.com/rpki.json)
17//! - **Format**: JSON with ROAs, ASPAs, and BGPsec keys
18//! - **Update Frequency**: Real-time
19//! - **Features**: Includes expiry timestamps for temporal validation
20//! - **Conditional loading**: [`RpkiTrie::from_cloudflare_conditional`] supports
21//!   `ETag`/`Last-Modified` validators so frequent pollers receive a cheap
22//!   `304 Not Modified` instead of re-downloading the full payload; responses
23//!   are gzip-compressed on the wire
24//!
25//! ## Historical Data (RIPE NCC)
26//! - **Source**: [RIPE NCC FTP archives](https://ftp.ripe.net/rpki/)
27//! - **Format**: JSON files (output.json.xz) with ROAs, ASPAs
28//! - **Use Case**: Historical analysis and research
29//! - **Date Range**: Configurable historical date
30//! - **TAL Sources**:
31//!     - AFRINIC: <https://ftp.ripe.net/rpki/afrinic.tal/>
32//!     - APNIC: <https://ftp.ripe.net/rpki/apnic.tal/>
33//!     - ARIN: <https://ftp.ripe.net/rpki/arin.tal/>
34//!     - LACNIC: <https://ftp.ripe.net/rpki/lacnic.tal/>
35//!     - RIPE NCC: <https://ftp.ripe.net/rpki/ripencc.tal/>
36//!
37//! ## Historical Data (RPKIviews)
38//! - **Source**: [RPKIviews](https://rpkiviews.org/)
39//! - **Format**: Compressed tarballs (.tgz) containing rpki-client.json
40//! - **Use Case**: Historical analysis from multiple vantage points
41//! - **Default Collector**: SobornostNet (josephine.sobornost.net)
42//! - **Collectors**:
43//!     - Josephine: A2B Internet (AS51088), Amsterdam, Netherlands
44//!     - Amber: Massar (AS57777), Lugano, Switzerland
45//!     - Dango: Internet Initiative Japan (AS2497), Tokyo, Japan
46//!     - Kerfuffle: Kerfuffle, LLC (AS35008), Fremont, California, United States
47//!
48//! ## Historical Data (RPKISPOOL)
49//! - **Format**: `.tar.zst` archives containing CCR (Canonical Cache Representation) files
50//! - **Use Case**: Efficient historical ROA/ASPA snapshots from collector-specific mirrors
51//! - **Collectors**: SobornostNet, AttnJp, and KerfuffleNet
52//!
53//! # Core Data Structures
54//!
55//! ## RpkiTrie
56//! The main data structure that stores RPKI data in a trie for efficient prefix lookups:
57//! - **Trie**: `IpnetTrie<Vec<Roa>>` - Maps IP prefixes to lists of ROA entries
58//! - **ASPAs**: `Vec<Aspa>` - AS Provider Authorization records
59//! - **Date**: `Option<NaiveDate>` - Optional date for historical data
60//!
61//! ## Roa
62//! Represents a Route Origin Authorization with the following fields:
63//! - `prefix: IpNet` - The IP prefix (e.g., 192.0.2.0/24)
64//! - `asn: u32` - The authorized ASN (e.g., 64496)
65//! - `max_length: u8` - Maximum allowed prefix length for more specifics
66//! - `rir: Option<Rir>` - Regional Internet Registry that issued the ROA
67//! - `not_before: Option<NaiveDateTime>` - ROA validity start time
68//! - `not_after: Option<NaiveDateTime>` - ROA validity end time (from expires field)
69//!
70//! ## Aspa
71//! Represents an AS Provider Authorization with the following fields:
72//! - `customer_asn: u32` - The customer AS number
73//! - `providers: Vec<u32>` - List of provider AS numbers
74//! - `expires: Option<NaiveDateTime>` - When this ASPA expires
75//!
76//! ## Validation Results
77//! RPKI validation returns one of three states:
78//! - **Valid**: The prefix-ASN pair is explicitly authorized by a valid ROA
79//! - **Invalid**: The prefix has ROAs but none authorize the given ASN
80//! - **Unknown**: No ROAs exist for the prefix, or all ROAs are outside their validity period
81//!
82//! # Usage Examples
83//!
84//! ## Loading Real-time Data (Cloudflare)
85//! ```rust,no_run
86//! use bgpkit_commons::BgpkitCommons;
87//!
88//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
89//! let mut commons = BgpkitCommons::new();
90//!
91//! // Load current RPKI data from Cloudflare
92//! commons.load_rpki(None)?;
93//!
94//! // Validate a prefix-ASN pair (standard validation)
95//! let result = commons.rpki_validate(64496, "192.0.2.0/24")?;
96//! match result {
97//!     bgpkit_commons::rpki::RpkiValidation::Valid => println!("Route is RPKI valid"),
98//!     bgpkit_commons::rpki::RpkiValidation::Invalid => println!("Route is RPKI invalid"),
99//!     bgpkit_commons::rpki::RpkiValidation::Unknown => println!("No RPKI data for this prefix"),
100//! }
101//! # Ok(())
102//! # }
103//! ```
104//!
105//! ## Loading Historical Data with Source Selection
106//! ```rust,no_run
107//! use bgpkit_commons::BgpkitCommons;
108//! use bgpkit_commons::rpki::{HistoricalRpkiSource, RpkiViewsCollector};
109//! use chrono::NaiveDate;
110//!
111//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
112//! let mut commons = BgpkitCommons::new();
113//! let date = NaiveDate::from_ymd_opt(2024, 1, 4).unwrap();
114//!
115//! // Load from RIPE NCC
116//! commons.load_rpki_historical(date, HistoricalRpkiSource::Ripe)?;
117//!
118//! // Or load from RPKIviews (uses the SobornostNet collector by default)
119//! let source = HistoricalRpkiSource::RpkiViews(RpkiViewsCollector::default());
120//! commons.load_rpki_historical(date, source)?;
121//! # Ok(())
122//! # }
123//! ```
124//!
125//! ## Listing Available Files
126//! ```rust,no_run
127//! use bgpkit_commons::BgpkitCommons;
128//! use bgpkit_commons::rpki::{HistoricalRpkiSource, RpkiViewsCollector};
129//! use chrono::NaiveDate;
130//!
131//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
132//! let commons = BgpkitCommons::new();
133//! let date = NaiveDate::from_ymd_opt(2024, 1, 4).unwrap();
134//!
135//! // List available files from RPKIviews (multiple snapshots per day)
136//! let source = HistoricalRpkiSource::RpkiViews(RpkiViewsCollector::default());
137//! let rpkiviews_files = commons.list_rpki_files(date, source)?;
138//! for file in &rpkiviews_files {
139//!     println!("RPKIviews file: {} (timestamp: {})", file.url, file.timestamp);
140//! }
141//! # Ok(())
142//! # }
143//! ```
144
145mod cloudflare;
146mod ripe_historical;
147pub(crate) mod rpki_client;
148mod rpkispools;
149mod rpkiviews;
150
151use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
152use ipnet::IpNet;
153use ipnet_trie::IpnetTrie;
154
155use crate::errors::{load_methods, modules};
156use crate::{BgpkitCommons, BgpkitCommonsError, LazyLoadable, Result};
157pub use cloudflare::RpkiLoad;
158pub use ripe_historical::list_ripe_files;
159use rpki_client::RpkiClientData;
160pub use rpkispools::{
161    RpkiSpoolsCollector, RpkiSpoolsData, list_rpkispools_files, parse_ccr, parse_rpkispools_archive,
162};
163pub use rpkiviews::{RpkiViewsCollector, list_rpkiviews_files};
164use serde::{Deserialize, Serialize};
165use std::fmt::Display;
166use std::str::FromStr;
167
168// ============================================================================
169// Public Data Structures
170// ============================================================================
171
172/// A validated Route Origin Authorization (ROA).
173///
174/// ROAs authorize specific Autonomous Systems to originate specific IP prefixes.
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct Roa {
177    /// The IP prefix (e.g., 192.0.2.0/24 or 2001:db8::/32)
178    pub prefix: IpNet,
179    /// The AS number authorized to originate this prefix
180    pub asn: u32,
181    /// Maximum prefix length allowed for announcements
182    pub max_length: u8,
183    /// Regional Internet Registry that issued this ROA
184    pub rir: Option<Rir>,
185    /// ROA validity start time (if available)
186    pub not_before: Option<NaiveDateTime>,
187    /// ROA validity end time (from expires field)
188    pub not_after: Option<NaiveDateTime>,
189}
190
191/// A validated AS Provider Authorization (ASPA).
192///
193/// ASPAs specify which ASes are authorized providers for a customer AS.
194#[derive(Clone, Debug, Serialize, Deserialize)]
195pub struct Aspa {
196    /// The customer AS number
197    pub customer_asn: u32,
198    /// List of provider AS numbers
199    pub providers: Vec<u32>,
200    /// When this ASPA expires
201    pub expires: Option<NaiveDateTime>,
202}
203
204/// Information about an available RPKI data file.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct RpkiFile {
207    /// Full URL to download the file
208    pub url: String,
209    /// Timestamp when the file was created
210    pub timestamp: DateTime<Utc>,
211    /// Size of the file in bytes (if available)
212    pub size: Option<u64>,
213    /// RIR that this file is for (for RIPE files)
214    pub rir: Option<Rir>,
215    /// Collector that provides this file (for RPKIviews files)
216    pub collector: Option<RpkiViewsCollector>,
217}
218
219/// Historical RPKI data source.
220///
221/// Used to specify which data source to use when loading historical RPKI data.
222#[derive(Debug, Clone, Default)]
223pub enum HistoricalRpkiSource {
224    /// RIPE NCC historical archives (data from all 5 RIRs)
225    #[default]
226    Ripe,
227    /// RPKIviews collector (tgz archives with rpki-client JSON)
228    RpkiViews(RpkiViewsCollector),
229    /// RPKISPOOL collector (tar.zst archives with CCR files)
230    RpkiSpools(RpkiSpoolsCollector),
231}
232
233impl std::fmt::Display for HistoricalRpkiSource {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            HistoricalRpkiSource::Ripe => write!(f, "RIPE NCC"),
237            HistoricalRpkiSource::RpkiViews(collector) => write!(f, "RPKIviews ({})", collector),
238            HistoricalRpkiSource::RpkiSpools(collector) => {
239                write!(f, "RPKISPOOL ({})", collector)
240            }
241        }
242    }
243}
244
245/// Regional Internet Registry (RIR).
246#[derive(Clone, Debug, Copy, PartialEq, Eq, Serialize, Deserialize)]
247pub enum Rir {
248    AFRINIC,
249    APNIC,
250    ARIN,
251    LACNIC,
252    RIPENCC,
253}
254
255impl FromStr for Rir {
256    type Err = String;
257
258    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
259        match s.to_lowercase().as_str() {
260            "afrinic" => Ok(Rir::AFRINIC),
261            "apnic" => Ok(Rir::APNIC),
262            "arin" => Ok(Rir::ARIN),
263            "lacnic" => Ok(Rir::LACNIC),
264            "ripe" => Ok(Rir::RIPENCC),
265            _ => Err(format!("unknown RIR: {}", s)),
266        }
267    }
268}
269
270impl Display for Rir {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        match self {
273            Rir::AFRINIC => write!(f, "AFRINIC"),
274            Rir::APNIC => write!(f, "APNIC"),
275            Rir::ARIN => write!(f, "ARIN"),
276            Rir::LACNIC => write!(f, "LACNIC"),
277            Rir::RIPENCC => write!(f, "RIPENCC"),
278        }
279    }
280}
281
282impl Rir {
283    pub fn to_ripe_ftp_root_url(&self) -> String {
284        match self {
285            Rir::AFRINIC => "https://ftp.ripe.net/rpki/afrinic.tal".to_string(),
286            Rir::APNIC => "https://ftp.ripe.net/rpki/apnic.tal".to_string(),
287            Rir::ARIN => "https://ftp.ripe.net/rpki/arin.tal".to_string(),
288            Rir::LACNIC => "https://ftp.ripe.net/rpki/lacnic.tal".to_string(),
289            Rir::RIPENCC => "https://ftp.ripe.net/rpki/ripencc.tal".to_string(),
290        }
291    }
292}
293
294/// RPKI validation result.
295#[derive(Clone, Debug, PartialEq, Eq)]
296pub enum RpkiValidation {
297    /// The prefix-ASN pair is explicitly authorized by a valid ROA
298    Valid,
299    /// The prefix has ROAs but none authorize the given ASN
300    Invalid,
301    /// No ROAs exist for the prefix, or all ROAs are outside their validity period
302    Unknown,
303}
304
305impl Display for RpkiValidation {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        match self {
308            RpkiValidation::Valid => write!(f, "valid"),
309            RpkiValidation::Invalid => write!(f, "invalid"),
310            RpkiValidation::Unknown => write!(f, "unknown"),
311        }
312    }
313}
314
315// ============================================================================
316// Backwards Compatibility Type Aliases
317// ============================================================================
318
319/// Type alias for backwards compatibility. Use [`Roa`] instead.
320/// Deprecated since 0.10.0. This alias will be removed in version 0.12.0.
321#[deprecated(since = "0.10.0", note = "Use Roa instead")]
322pub type RoaEntry = Roa;
323
324// ============================================================================
325// RpkiTrie Implementation
326// ============================================================================
327
328/// The main RPKI data structure storing ROAs and ASPAs.
329#[derive(Clone)]
330pub struct RpkiTrie {
331    /// Trie mapping IP prefixes to ROA entries
332    pub trie: IpnetTrie<Vec<Roa>>,
333    /// AS Provider Authorizations
334    pub aspas: Vec<Aspa>,
335    /// Date for historical data (None for real-time)
336    date: Option<NaiveDate>,
337}
338
339impl Default for RpkiTrie {
340    fn default() -> Self {
341        Self {
342            trie: IpnetTrie::new(),
343            aspas: vec![],
344            date: None,
345        }
346    }
347}
348
349impl RpkiTrie {
350    /// Create a new empty RpkiTrie.
351    pub fn new(date: Option<NaiveDate>) -> Self {
352        Self {
353            trie: IpnetTrie::new(),
354            aspas: vec![],
355            date,
356        }
357    }
358
359    /// Insert a ROA. Returns true if this is a new prefix, false if added to existing prefix.
360    /// Duplicates are avoided - ROAs with same (prefix, asn, max_length) are considered identical.
361    pub fn insert_roa(&mut self, roa: Roa) -> bool {
362        match self.trie.exact_match_mut(roa.prefix) {
363            Some(existing_roas) => {
364                // Check if this ROA already exists (same prefix, asn, max_length)
365                if !existing_roas.iter().any(|existing| {
366                    existing.asn == roa.asn && existing.max_length == roa.max_length
367                }) {
368                    existing_roas.push(roa);
369                }
370                false
371            }
372            None => {
373                self.trie.insert(roa.prefix, vec![roa]);
374                true
375            }
376        }
377    }
378
379    /// Insert multiple ROAs.
380    pub fn insert_roas(&mut self, roas: Vec<Roa>) {
381        for roa in roas {
382            self.insert_roa(roa);
383        }
384    }
385
386    /// Convert rpki-client data into an RpkiTrie.
387    ///
388    /// This is a shared conversion function used by all data sources
389    /// (Cloudflare, RIPE, RPKIviews) since they all produce the same
390    /// rpki-client JSON format.
391    pub(crate) fn from_rpki_client_data(
392        data: RpkiClientData,
393        date: Option<NaiveDate>,
394    ) -> Result<Self> {
395        let mut trie = RpkiTrie::new(date);
396        trie.merge_rpki_client_data(data);
397        Ok(trie)
398    }
399
400    /// Merge rpki-client data into this trie.
401    ///
402    /// This converts ROAs and ASPAs from rpki-client format and inserts them,
403    /// avoiding duplicates for ASPAs based on customer_asn.
404    pub(crate) fn merge_rpki_client_data(&mut self, data: RpkiClientData) {
405        // Convert and insert ROAs
406        for roa in data.roas {
407            let prefix = match roa.prefix.parse::<IpNet>() {
408                Ok(p) => p,
409                Err(_) => continue,
410            };
411            let rir = Rir::from_str(&roa.ta).ok();
412            let not_after =
413                DateTime::from_timestamp(roa.expires as i64, 0).map(|dt| dt.naive_utc());
414
415            self.insert_roa(Roa {
416                prefix,
417                asn: roa.asn,
418                max_length: roa.max_length,
419                rir,
420                not_before: None,
421                not_after,
422            });
423        }
424
425        // Convert and merge ASPAs (avoiding duplicates based on customer_asn)
426        for aspa in data.aspas {
427            if !self
428                .aspas
429                .iter()
430                .any(|a| a.customer_asn == aspa.customer_asid)
431            {
432                let expires = DateTime::from_timestamp(aspa.expires, 0).map(|dt| dt.naive_utc());
433                self.aspas.push(Aspa {
434                    customer_asn: aspa.customer_asid,
435                    providers: aspa.providers,
436                    expires,
437                });
438            }
439        }
440    }
441
442    /// Lookup all ROAs that authorize a given prefix (matching ASN and max_length).
443    pub fn lookup_by_prefix(&self, prefix: &IpNet) -> Vec<Roa> {
444        let mut all_matches = vec![];
445        for (p, roas) in self.trie.matches(prefix) {
446            if p.contains(prefix) {
447                for roa in roas {
448                    if roa.max_length >= prefix.prefix_len() {
449                        all_matches.push(roa.clone());
450                    }
451                }
452            }
453        }
454        all_matches
455    }
456
457    /// Lookup all ROAs that cover a given prefix, regardless of max_length.
458    ///
459    /// This returns all ROAs whose prefix contains the given prefix,
460    /// without filtering by max_length. Used to determine if a prefix
461    /// is covered by RPKI data at all.
462    fn lookup_covering_roas(&self, prefix: &IpNet) -> Vec<Roa> {
463        let mut all_matches = vec![];
464        for (p, roas) in self.trie.matches(prefix) {
465            if p.contains(prefix) {
466                for roa in roas {
467                    all_matches.push(roa.clone());
468                }
469            }
470        }
471        all_matches
472    }
473
474    /// Validate a prefix with an ASN.
475    ///
476    /// Return values:
477    /// - `RpkiValidation::Valid` if the prefix-asn pair is valid
478    /// - `RpkiValidation::Invalid` if the prefix-asn pair is invalid
479    /// - `RpkiValidation::Unknown` if the prefix-asn pair is not found in RPKI
480    pub fn validate(&self, prefix: &IpNet, asn: u32) -> RpkiValidation {
481        // First check if there are ANY covering ROAs (regardless of max_length)
482        let covering_roas = self.lookup_covering_roas(prefix);
483        if covering_roas.is_empty() {
484            return RpkiValidation::Unknown;
485        }
486
487        // Now check for valid matches (matching ASN and max_length)
488        let matches = self.lookup_by_prefix(prefix);
489        for roa in matches {
490            if roa.asn == asn && roa.max_length >= prefix.prefix_len() {
491                return RpkiValidation::Valid;
492            }
493        }
494        // There are covering ROAs but none authorize this prefix/ASN
495        RpkiValidation::Invalid
496    }
497
498    /// Validate a prefix with an ASN, checking expiry dates.
499    ///
500    /// Return values:
501    /// - `RpkiValidation::Valid` if the prefix-asn pair is valid and not expired
502    /// - `RpkiValidation::Invalid` if the prefix-asn pair is invalid (wrong ASN or max_length exceeded)
503    /// - `RpkiValidation::Unknown` if the prefix-asn pair is not found in RPKI or all matching ROAs are outside their valid time range
504    pub fn validate_check_expiry(
505        &self,
506        prefix: &IpNet,
507        asn: u32,
508        check_time: Option<NaiveDateTime>,
509    ) -> RpkiValidation {
510        // First check if there are ANY covering ROAs (regardless of max_length)
511        let covering_roas = self.lookup_covering_roas(prefix);
512        if covering_roas.is_empty() {
513            return RpkiValidation::Unknown;
514        }
515
516        let check_time = check_time.unwrap_or_else(|| Utc::now().naive_utc());
517
518        let mut found_matching_asn = false;
519
520        // Check for valid matches (matching ASN and max_length)
521        let matches = self.lookup_by_prefix(prefix);
522        for roa in matches {
523            if roa.asn == asn && roa.max_length >= prefix.prefix_len() {
524                found_matching_asn = true;
525
526                // Check if ROA is within valid time period
527                let is_valid_time = {
528                    if let Some(not_before) = roa.not_before {
529                        if check_time < not_before {
530                            false // ROA not yet valid
531                        } else {
532                            true
533                        }
534                    } else {
535                        true // no not_before constraint
536                    }
537                } && {
538                    if let Some(not_after) = roa.not_after {
539                        if check_time > not_after {
540                            false // ROA expired
541                        } else {
542                            true
543                        }
544                    } else {
545                        true // no not_after constraint
546                    }
547                };
548
549                if is_valid_time {
550                    return RpkiValidation::Valid;
551                }
552            }
553        }
554
555        // If we found matching ASN but all ROAs are outside valid time range, return Unknown
556        if found_matching_asn {
557            return RpkiValidation::Unknown;
558        }
559
560        // There are covering ROAs but none authorize this prefix/ASN
561        RpkiValidation::Invalid
562    }
563
564    /// Reload the RPKI data from its original source.
565    pub fn reload(&mut self) -> Result<()> {
566        match self.date {
567            Some(date) => {
568                let trie = RpkiTrie::from_ripe_historical(date)?;
569                self.trie = trie.trie;
570                self.aspas = trie.aspas;
571            }
572            None => {
573                let trie = RpkiTrie::from_cloudflare()?;
574                self.trie = trie.trie;
575                self.aspas = trie.aspas;
576            }
577        }
578
579        Ok(())
580    }
581}
582
583impl LazyLoadable for RpkiTrie {
584    fn reload(&mut self) -> Result<()> {
585        self.reload()
586    }
587
588    fn is_loaded(&self) -> bool {
589        !self.trie.is_empty()
590    }
591
592    fn loading_status(&self) -> &'static str {
593        if self.is_loaded() {
594            "RPKI data loaded"
595        } else {
596            "RPKI data not loaded"
597        }
598    }
599}
600
601// ============================================================================
602// BgpkitCommons Integration
603// ============================================================================
604
605impl BgpkitCommons {
606    pub fn rpki_lookup_by_prefix(&self, prefix: &str) -> Result<Vec<Roa>> {
607        if self.rpki_trie.is_none() {
608            return Err(BgpkitCommonsError::module_not_loaded(
609                modules::RPKI,
610                load_methods::LOAD_RPKI,
611            ));
612        }
613
614        let prefix = prefix.parse()?;
615
616        Ok(self.rpki_trie.as_ref().unwrap().lookup_by_prefix(&prefix))
617    }
618
619    pub fn rpki_validate(&self, asn: u32, prefix: &str) -> Result<RpkiValidation> {
620        if self.rpki_trie.is_none() {
621            return Err(BgpkitCommonsError::module_not_loaded(
622                modules::RPKI,
623                load_methods::LOAD_RPKI,
624            ));
625        }
626        let prefix = prefix.parse()?;
627        Ok(self.rpki_trie.as_ref().unwrap().validate(&prefix, asn))
628    }
629
630    pub fn rpki_validate_check_expiry(
631        &self,
632        asn: u32,
633        prefix: &str,
634        check_time: Option<NaiveDateTime>,
635    ) -> Result<RpkiValidation> {
636        if self.rpki_trie.is_none() {
637            return Err(BgpkitCommonsError::module_not_loaded(
638                modules::RPKI,
639                load_methods::LOAD_RPKI,
640            ));
641        }
642        let prefix = prefix.parse()?;
643        Ok(self
644            .rpki_trie
645            .as_ref()
646            .unwrap()
647            .validate_check_expiry(&prefix, asn, check_time))
648    }
649
650    /// Look up ASPA records for a given customer ASN.
651    ///
652    /// Returns the ASPA record if one exists for the given customer ASN,
653    /// or `None` if no ASPA is registered.
654    pub fn rpki_lookup_aspa(&self, customer_asn: u32) -> Result<Option<Aspa>> {
655        if self.rpki_trie.is_none() {
656            return Err(BgpkitCommonsError::module_not_loaded(
657                modules::RPKI,
658                load_methods::LOAD_RPKI,
659            ));
660        }
661        Ok(self
662            .rpki_trie
663            .as_ref()
664            .unwrap()
665            .aspas
666            .iter()
667            .find(|a| a.customer_asn == customer_asn)
668            .cloned())
669    }
670}
671
672// ============================================================================
673// Tests
674// ============================================================================
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use chrono::DateTime;
680
681    #[test]
682    fn test_multiple_roas_same_prefix() {
683        let mut trie = RpkiTrie::new(None);
684
685        // Insert first ROA
686        let roa1 = Roa {
687            prefix: "192.0.2.0/24".parse().unwrap(),
688            asn: 64496,
689            max_length: 24,
690            rir: Some(Rir::APNIC),
691            not_before: None,
692            not_after: None,
693        };
694        assert!(trie.insert_roa(roa1.clone()));
695
696        // Insert second ROA with different ASN for same prefix
697        let roa2 = Roa {
698            prefix: "192.0.2.0/24".parse().unwrap(),
699            asn: 64497,
700            max_length: 24,
701            rir: Some(Rir::APNIC),
702            not_before: None,
703            not_after: None,
704        };
705        assert!(!trie.insert_roa(roa2.clone()));
706
707        // Insert duplicate ROA (same prefix, asn, max_length) - should be ignored
708        let roa_dup = Roa {
709            prefix: "192.0.2.0/24".parse().unwrap(),
710            asn: 64496,
711            max_length: 24,
712            rir: Some(Rir::ARIN), // Different RIR shouldn't matter
713            not_before: None,
714            not_after: None,
715        };
716        assert!(!trie.insert_roa(roa_dup));
717
718        // Insert ROA with different max_length - should be added
719        let roa3 = Roa {
720            prefix: "192.0.2.0/24".parse().unwrap(),
721            asn: 64496,
722            max_length: 28,
723            rir: Some(Rir::APNIC),
724            not_before: None,
725            not_after: None,
726        };
727        assert!(!trie.insert_roa(roa3.clone()));
728
729        // Lookup should return 3 ROAs (roa1, roa2, roa3)
730        let prefix: IpNet = "192.0.2.0/24".parse().unwrap();
731        let roas = trie.lookup_by_prefix(&prefix);
732        assert_eq!(roas.len(), 3);
733
734        // Validate AS 64496 - should be valid
735        assert_eq!(trie.validate(&prefix, 64496), RpkiValidation::Valid);
736
737        // Validate AS 64497 - should be valid
738        assert_eq!(trie.validate(&prefix, 64497), RpkiValidation::Valid);
739
740        // Validate AS 64498 - should be invalid (prefix has ROAs but not for this ASN)
741        assert_eq!(trie.validate(&prefix, 64498), RpkiValidation::Invalid);
742
743        // Validate unknown prefix - should be unknown
744        let unknown_prefix: IpNet = "10.0.0.0/8".parse().unwrap();
745        assert_eq!(
746            trie.validate(&unknown_prefix, 64496),
747            RpkiValidation::Unknown
748        );
749    }
750
751    #[test]
752    fn test_validate_check_expiry_with_time_constraints() {
753        let mut trie = RpkiTrie::new(None);
754
755        // Time references
756        let past_time = DateTime::from_timestamp(1600000000, 0)
757            .map(|dt| dt.naive_utc())
758            .unwrap();
759        let current_time = DateTime::from_timestamp(1700000000, 0)
760            .map(|dt| dt.naive_utc())
761            .unwrap();
762        let future_time = DateTime::from_timestamp(1800000000, 0)
763            .map(|dt| dt.naive_utc())
764            .unwrap();
765
766        // Insert ROA that's currently valid (not_before in past, not_after in future)
767        let roa_valid = Roa {
768            prefix: "192.0.2.0/24".parse().unwrap(),
769            asn: 64496,
770            max_length: 24,
771            rir: Some(Rir::APNIC),
772            not_before: Some(past_time),
773            not_after: Some(future_time),
774        };
775        trie.insert_roa(roa_valid);
776
777        // Insert ROA that's expired
778        let roa_expired = Roa {
779            prefix: "198.51.100.0/24".parse().unwrap(),
780            asn: 64497,
781            max_length: 24,
782            rir: Some(Rir::APNIC),
783            not_before: Some(past_time),
784            not_after: Some(past_time), // Expired in the past
785        };
786        trie.insert_roa(roa_expired);
787
788        // Insert ROA that's not yet valid
789        let roa_future = Roa {
790            prefix: "203.0.113.0/24".parse().unwrap(),
791            asn: 64498,
792            max_length: 24,
793            rir: Some(Rir::APNIC),
794            not_before: Some(future_time), // Not valid yet
795            not_after: None,
796        };
797        trie.insert_roa(roa_future);
798
799        // Test valid ROA at current time
800        let prefix_valid: IpNet = "192.0.2.0/24".parse().unwrap();
801        assert_eq!(
802            trie.validate_check_expiry(&prefix_valid, 64496, Some(current_time)),
803            RpkiValidation::Valid
804        );
805
806        // Test expired ROA at current time - should return Unknown (was valid but expired)
807        let prefix_expired: IpNet = "198.51.100.0/24".parse().unwrap();
808        assert_eq!(
809            trie.validate_check_expiry(&prefix_expired, 64497, Some(current_time)),
810            RpkiValidation::Unknown
811        );
812
813        // Test not-yet-valid ROA at current time - should return Unknown
814        let prefix_future: IpNet = "203.0.113.0/24".parse().unwrap();
815        assert_eq!(
816            trie.validate_check_expiry(&prefix_future, 64498, Some(current_time)),
817            RpkiValidation::Unknown
818        );
819
820        // Test not-yet-valid ROA at future time - should return Valid
821        let far_future = DateTime::from_timestamp(1900000000, 0)
822            .map(|dt| dt.naive_utc())
823            .unwrap();
824        assert_eq!(
825            trie.validate_check_expiry(&prefix_future, 64498, Some(far_future)),
826            RpkiValidation::Valid
827        );
828
829        // Test wrong ASN - should return Invalid
830        assert_eq!(
831            trie.validate_check_expiry(&prefix_valid, 64499, Some(current_time)),
832            RpkiValidation::Invalid
833        );
834    }
835
836    #[test]
837    #[ignore] // Requires network access
838    fn test_load_from_ripe_historical() {
839        // Use a recent date that should have data available
840        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
841        let trie = RpkiTrie::from_ripe_historical(date).expect("Failed to load RIPE data");
842
843        let total_roas: usize = trie.trie.iter().map(|(_, roas)| roas.len()).sum();
844        println!(
845            "Loaded {} ROAs from RIPE historical for {}",
846            total_roas, date
847        );
848        println!("Loaded {} ASPAs", trie.aspas.len());
849
850        assert!(total_roas > 0, "Should have loaded some ROAs");
851    }
852
853    #[test]
854    #[ignore] // Requires network access
855    fn test_load_from_rpkiviews() {
856        // Note: This test streams from a remote tgz file but stops early
857        // once rpki-client.json is found (typically at position 3-4 in the archive).
858        // Due to streaming optimization, this typically completes in ~8 seconds.
859        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
860        let trie = RpkiTrie::from_rpkiviews(RpkiViewsCollector::default(), date)
861            .expect("Failed to load RPKIviews data");
862
863        let total_roas: usize = trie.trie.iter().map(|(_, roas)| roas.len()).sum();
864        println!("Loaded {} ROAs from RPKIviews for {}", total_roas, date);
865        println!("Loaded {} ASPAs", trie.aspas.len());
866
867        assert!(total_roas > 0, "Should have loaded some ROAs");
868    }
869
870    #[test]
871    #[ignore] // Requires network access
872    fn test_rpkiviews_file_position() {
873        // Verify that rpki-client.json appears early in the archive
874        // This confirms our early-termination optimization works
875        use crate::rpki::rpkiviews::list_files_in_tgz;
876
877        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
878        let files = list_rpkiviews_files(RpkiViewsCollector::default(), date)
879            .expect("Failed to list files");
880
881        assert!(!files.is_empty(), "Should have found some files");
882
883        let tgz_url = &files[0].url;
884        println!("Checking file positions in: {}", tgz_url);
885
886        // List first 50 entries to see where rpki-client.json appears
887        let entries = list_files_in_tgz(tgz_url, Some(50)).expect("Failed to list tgz entries");
888
889        let json_position = entries
890            .iter()
891            .position(|e| e.path.ends_with("rpki-client.json"));
892
893        println!("First {} entries:", entries.len());
894        for (i, entry) in entries.iter().enumerate() {
895            println!("  [{}] {} ({} bytes)", i, entry.path, entry.size);
896        }
897
898        if let Some(pos) = json_position {
899            println!(
900                "\nrpki-client.json found at position {} (early in archive)",
901                pos
902            );
903            assert!(
904                pos < 50,
905                "rpki-client.json should appear early in the archive"
906            );
907        } else {
908            println!("\nrpki-client.json not in first 50 entries - may need to stream more");
909        }
910    }
911
912    #[test]
913    #[ignore] // Requires network access
914    fn test_list_rpkiviews_files() {
915        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
916        let files = list_rpkiviews_files(RpkiViewsCollector::default(), date)
917            .expect("Failed to list files");
918
919        println!("Found {} files for {} from Kerfuffle", files.len(), date);
920        for file in files.iter().take(3) {
921            println!(
922                "  {} ({} bytes, {})",
923                file.url,
924                file.size.unwrap_or(0),
925                file.timestamp
926            );
927        }
928
929        assert!(!files.is_empty(), "Should have found some files");
930    }
931
932    #[test]
933    fn test_validate_max_length_exceeded() {
934        // Test the bug where a prefix covered by an ROA but with max_length exceeded
935        // should return Invalid, not Unknown
936        let mut trie = RpkiTrie::new(None);
937
938        // Insert ROA for /23 with max_length 23 (no more specific allowed)
939        let roa = Roa {
940            prefix: "103.21.244.0/23".parse().unwrap(),
941            asn: 13335, // Cloudflare
942            max_length: 23,
943            rir: Some(Rir::APNIC),
944            not_before: None,
945            not_after: None,
946        };
947        trie.insert_roa(roa);
948
949        // /24 is covered by /23 but max_length is 23, so this should be Invalid
950        let prefix_24: IpNet = "103.21.244.0/24".parse().unwrap();
951
952        // Test with correct ASN - should be Invalid (covered by RPKI but not authorized due to max_length)
953        assert_eq!(
954            trie.validate(&prefix_24, 13335),
955            RpkiValidation::Invalid,
956            "Prefix covered by ROA but max_length exceeded should be Invalid"
957        );
958
959        // Test with wrong ASN - should also be Invalid
960        assert_eq!(
961            trie.validate(&prefix_24, 64496),
962            RpkiValidation::Invalid,
963            "Prefix covered by ROA with wrong ASN should be Invalid"
964        );
965
966        // The /23 itself with correct ASN should be Valid
967        let prefix_23: IpNet = "103.21.244.0/23".parse().unwrap();
968        assert_eq!(
969            trie.validate(&prefix_23, 13335),
970            RpkiValidation::Valid,
971            "Exact prefix match with correct ASN should be Valid"
972        );
973
974        // Completely unrelated prefix should be Unknown
975        let unknown_prefix: IpNet = "10.0.0.0/8".parse().unwrap();
976        assert_eq!(
977            trie.validate(&unknown_prefix, 13335),
978            RpkiValidation::Unknown,
979            "Prefix not covered by any ROA should be Unknown"
980        );
981    }
982
983    #[test]
984    fn test_validate_check_expiry_max_length_exceeded() {
985        // Same test but for validate_check_expiry
986        let mut trie = RpkiTrie::new(None);
987
988        let current_time = DateTime::from_timestamp(1700000000, 0)
989            .map(|dt| dt.naive_utc())
990            .unwrap();
991        let future_time = DateTime::from_timestamp(1800000000, 0)
992            .map(|dt| dt.naive_utc())
993            .unwrap();
994
995        // Insert ROA for /23 with max_length 23
996        let roa = Roa {
997            prefix: "103.21.244.0/23".parse().unwrap(),
998            asn: 13335,
999            max_length: 23,
1000            rir: Some(Rir::APNIC),
1001            not_before: Some(current_time),
1002            not_after: Some(future_time),
1003        };
1004        trie.insert_roa(roa);
1005
1006        // /24 is covered by /23 but max_length is 23, so this should be Invalid
1007        let prefix_24: IpNet = "103.21.244.0/24".parse().unwrap();
1008
1009        // Test with correct ASN - should be Invalid
1010        assert_eq!(
1011            trie.validate_check_expiry(&prefix_24, 13335, Some(current_time)),
1012            RpkiValidation::Invalid,
1013            "Prefix covered by ROA but max_length exceeded should be Invalid"
1014        );
1015
1016        // Test with wrong ASN - should also be Invalid
1017        assert_eq!(
1018            trie.validate_check_expiry(&prefix_24, 64496, Some(current_time)),
1019            RpkiValidation::Invalid,
1020            "Prefix covered by ROA with wrong ASN should be Invalid"
1021        );
1022
1023        // The /23 itself with correct ASN should be Valid
1024        let prefix_23: IpNet = "103.21.244.0/23".parse().unwrap();
1025        assert_eq!(
1026            trie.validate_check_expiry(&prefix_23, 13335, Some(current_time)),
1027            RpkiValidation::Valid,
1028            "Exact prefix match with correct ASN should be Valid"
1029        );
1030
1031        // Completely unrelated prefix should be Unknown
1032        let unknown_prefix: IpNet = "10.0.0.0/8".parse().unwrap();
1033        assert_eq!(
1034            trie.validate_check_expiry(&unknown_prefix, 13335, Some(current_time)),
1035            RpkiValidation::Unknown,
1036            "Prefix not covered by any ROA should be Unknown"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_lookup_covering_roas() {
1042        // Test the helper method that finds all covering ROAs
1043        let mut trie = RpkiTrie::new(None);
1044
1045        // Insert ROA for /23 with max_length 23
1046        let roa = Roa {
1047            prefix: "103.21.244.0/23".parse().unwrap(),
1048            asn: 13335,
1049            max_length: 23,
1050            rir: Some(Rir::APNIC),
1051            not_before: None,
1052            not_after: None,
1053        };
1054        trie.insert_roa(roa);
1055
1056        // Insert another ROA for a different prefix
1057        let roa2 = Roa {
1058            prefix: "192.0.2.0/24".parse().unwrap(),
1059            asn: 64496,
1060            max_length: 24,
1061            rir: Some(Rir::ARIN),
1062            not_before: None,
1063            not_after: None,
1064        };
1065        trie.insert_roa(roa2);
1066
1067        // lookup_covering_roas should find the /23 ROA for the /24 prefix
1068        let prefix_24: IpNet = "103.21.244.0/24".parse().unwrap();
1069        let covering = trie.lookup_covering_roas(&prefix_24);
1070        assert_eq!(covering.len(), 1, "Should find 1 covering ROA");
1071        assert_eq!(covering[0].asn, 13335);
1072
1073        // lookup_by_prefix should return empty (max_length filter)
1074        let matching = trie.lookup_by_prefix(&prefix_24);
1075        assert!(
1076            matching.is_empty(),
1077            "lookup_by_prefix should filter by max_length"
1078        );
1079
1080        // For the exact /23 prefix, both should return the ROA
1081        let prefix_23: IpNet = "103.21.244.0/23".parse().unwrap();
1082        let covering_exact = trie.lookup_covering_roas(&prefix_23);
1083        let matching_exact = trie.lookup_by_prefix(&prefix_23);
1084        assert_eq!(covering_exact.len(), 1);
1085        assert_eq!(matching_exact.len(), 1);
1086
1087        // Unrelated prefix should find nothing
1088        let unknown_prefix: IpNet = "10.0.0.0/8".parse().unwrap();
1089        assert!(trie.lookup_covering_roas(&unknown_prefix).is_empty());
1090        assert!(trie.lookup_by_prefix(&unknown_prefix).is_empty());
1091    }
1092}