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