Skip to main content

bgpkit_commons/asinfo/
mod.rs

1//! asinfo is a module for simple Autonomous System (AS) names and country lookup
2//!
3//! # Data source
4//!
5//! - RIPE NCC asinfo: <https://ftp.ripe.net/ripe/asnames/asn.txt>
6//! - RIR delegated stats (authoritative allocation records, attached to every ASN):
7//!   <https://www.nro.net/about/rirs/statistics/>
8//! - IRR `aut-num`, `route`/`route6` objects (per-source arrays for every ASN
9//!   with IRR registrations): RIPE, APNIC, ARIN, LACNIC, AFRINIC, NTTCOM, RADB
10//! - (Optional) CAIDA as-to-organization mapping: <https://www.caida.org/catalog/datasets/as-organizations/>
11//! - (Optional) APNIC AS population data: <https://stats.labs.apnic.net/cgi-bin/aspop>
12//! - (Optional) IIJ IHR Hegemony data: <https://ihr-archive.iijlab.net/>
13//! - (Optional) PeeringDB data: <https://www.peeringdb.com>
14//!
15//! # Data structure
16//!
17//! ```rust
18//! use bgpkit_commons::asinfo::AsInfo;
19//!
20//! fn inspect(info: &AsInfo) {
21//!     println!("AS{}: {} ({})", info.asn, info.name, info.country);
22//!     println!("delegated: {:?}", info.delegated);
23//!     println!("IRR registries: {}", info.irr.len());
24//! }
25//! ```
26//!
27//! The `peeringdb` field of `AsInfo` uses [`crate::peeringdb::Network`], which
28//! mirrors the full PeeringDB `/net` API record.
29//!
30//! # Example
31//!
32//! Call with `BgpkitCommons` instance:
33//!
34//! ```rust,no_run
35//! use bgpkit_commons::BgpkitCommons;
36//!
37//! let mut bgpkit = BgpkitCommons::new();
38//! bgpkit.load_asinfo_with_profile(Default::default()).unwrap();
39//! let asinfo = bgpkit.asinfo_get(3333).unwrap().unwrap();
40//! assert_eq!(asinfo.name, "RIPE-NCC-AS Reseaux IP Europeens Network Coordination Centre (RIPE NCC)");
41//! ```
42//!
43//! Directly call the module:
44//!
45//! ```rust,no_run
46//! use bgpkit_commons::asinfo::AsInfoBuilder;
47//!
48//! let _asinfo = AsInfoBuilder::new().build().unwrap();
49//! ```
50//!
51//! Retrieve all previously generated and cached AS information:
52//! ```rust,no_run
53//! use std::collections::HashMap;
54//! use bgpkit_commons::asinfo::{get_asinfo_map_cached, AsInfo};
55//! let asinfo: HashMap<u32, AsInfo> = get_asinfo_map_cached().unwrap();
56//! assert_eq!(asinfo.get(&3333).unwrap().name, "RIPE-NCC-AS Reseaux IP Europeens Network Coordination Centre (RIPE NCC)");
57//! assert_eq!(asinfo.get(&400644).unwrap().name, "BGPKIT-LLC");
58//! assert_eq!(asinfo.get(&400644).unwrap().country, "US");
59//! ```
60//!
61//! Or with `BgpkitCommons` instance:
62//! ```rust,no_run
63//!
64//! use std::collections::HashMap;
65//! use bgpkit_commons::asinfo::AsInfo;
66//! use bgpkit_commons::BgpkitCommons;
67//!
68//! let mut commons = BgpkitCommons::new();
69//! commons.load_asinfo_cached().unwrap();
70//! let asinfo: HashMap<u32, AsInfo> = commons.asinfo_all().unwrap();
71//! assert_eq!(asinfo.get(&3333).unwrap().name, "RIPE-NCC-AS Reseaux IP Europeens Network Coordination Centre (RIPE NCC)");
72//! assert_eq!(asinfo.get(&400644).unwrap().name, "BGPKIT-LLC");
73//! assert_eq!(asinfo.get(&400644).unwrap().country, "US");
74//! ```
75//!
76//! Check if two ASNs are siblings:
77//!
78//! ```rust,no_run
79//! use bgpkit_commons::BgpkitCommons;
80//!
81//! let mut bgpkit = BgpkitCommons::new();
82//! bgpkit.load_asinfo_with(bgpkit.asinfo_builder().with_as2org()).unwrap();
83//! let are_siblings = bgpkit.asinfo_are_siblings(3333, 3334).unwrap();
84//! ```
85
86mod as2org;
87mod hegemony;
88mod population;
89mod sibling_orgs;
90
91use crate::errors::{data_sources, load_methods, modules};
92use crate::peeringdb::{Network, Peeringdb};
93use crate::{BgpkitCommons, BgpkitCommonsError, LazyLoadable, Result};
94use ipnet::{IpNet, Ipv4Net, Ipv6Net};
95use serde::{Deserialize, Serialize};
96use sibling_orgs::SiblingOrgsUtils;
97use std::collections::HashMap;
98use std::io::{BufRead, Read};
99use tracing::{info, warn};
100
101pub use hegemony::HegemonyData;
102pub use population::AsnPopulationData;
103
104/// RIR delegated-stats data for a single ASN.
105///
106/// Sourced from the five RIR delegated stats files (NRO format). These are
107/// authoritative allocation records, updated daily. For each ASN, the
108/// registry that allocated it, the allocation date, status, and country
109/// code are recorded.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct DelegatedInfo {
112    /// The RIR that allocated/assigned this ASN (e.g. `"ripencc"`, `"arin"`).
113    pub registry: String,
114    /// The ISO 3166-1 alpha-2 country code (uppercased).
115    pub country: String,
116    /// The allocation/assignment date (as-is from the record, format: `YYYYMMDD`).
117    pub date: String,
118    /// The allocation status (`"allocated"` or `"assigned"`).
119    pub status: String,
120}
121
122/// IRR data for a single ASN from a single registry source.
123///
124/// Each entry corresponds to one IRR registry's view of this ASN. An ASN may
125/// have entries from multiple registries (e.g. both RIPE and RADB) — the
126/// `irr` field on [`AsInfo`] is a `Vec<IrrAsnInfo>` so callers can pick which
127/// source(s) to trust.
128///
129/// Provenance is preserved via `source` (the registry name from the RPSL
130/// `source:` attribute). IRR data is self-registered; trust varies by
131/// registry authorization model.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct IrrAsnInfo {
134    /// The `as-name` attribute from the IRR `aut-num` object.
135    pub as_name: String,
136    /// The `descr` attribute(s), if any.
137    pub descr: Vec<String>,
138    /// The `source:` attribute — which IRR registry published this object.
139    pub source: String,
140    /// The `mnt-by` attribute(s) — maintainers controlling this object.
141    pub mnt_by: Vec<String>,
142    /// Registered IPv4 prefixes from `route` objects with this ASN as origin.
143    pub route_prefixes: Vec<Ipv4Net>,
144    /// Registered IPv6 prefixes from `route6` objects with this ASN as origin.
145    pub route6_prefixes: Vec<Ipv6Net>,
146    /// AS-set names that contain this ASN as a direct member.
147    pub member_of_sets: Vec<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AsInfo {
152    pub asn: u32,
153    pub name: String,
154    pub country: String,
155    /// Serde defaults on every optional field keep newly-serialized records
156    /// (which omit absent fields) readable by the same struct on deserialization.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub as2org: Option<As2orgInfo>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub population: Option<AsnPopulationData>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub hegemony: Option<HegemonyData>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub peeringdb: Option<Network>,
165    /// RIR delegated-stats allocation data. Present for every allocated ASN.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub delegated: Option<DelegatedInfo>,
168    /// IRR data per registry source. Empty if the ASN has no IRR registrations.
169    /// Multiple sources may have data; callers choose which to trust.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub irr: Vec<IrrAsnInfo>,
172}
173
174impl AsInfo {
175    /// Returns the preferred name for the AS.
176    ///
177    /// The order of preference is:
178    /// 1. `peeringdb.name` if available
179    /// 2. `as2org.org_name` if available and not empty
180    /// 3. The default `name` field
181    ///
182    /// This method does not perform any network access.
183    pub fn get_preferred_name(&self) -> String {
184        if let Some(peeringdb_data) = &self.peeringdb {
185            if let Some(name) = &peeringdb_data.name {
186                if !name.is_empty() {
187                    return name.clone();
188                }
189            }
190        }
191        if let Some(as2org_info) = &self.as2org {
192            if !as2org_info.org_name.is_empty() {
193                return as2org_info.org_name.clone();
194            }
195        }
196        self.name.clone()
197    }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct As2orgInfo {
202    pub name: String,
203    pub country: String,
204    pub org_id: String,
205    pub org_name: String,
206}
207
208const RIPE_RIS_ASN_TXT_URL: &str = "https://ftp.ripe.net/ripe/asnames/asn.txt";
209const BGPKIT_ASN_TXT_MIRROR_URL: &str = "https://data.bgpkit.com/commons/asn.txt";
210const BGPKIT_ASNINFO_URL: &str = "https://data.bgpkit.com/commons/asinfo.jsonl";
211
212/// Configuration for which IRR sources to fetch.
213///
214/// By default, `with_irr()` uses every catalogued source.
215/// For finer control, use `with_irr_sources()` to pick specific registries.
216///
217/// # Example
218///
219/// ```rust,no_run
220/// use bgpkit_commons::asinfo::AsInfoBuilder;
221/// use bgpkit_commons::asinfo::IrrSourceConfig;
222///
223/// // Only RIPE + RADB
224/// let config = IrrSourceConfig::only(&["RIPE", "RADB"]).unwrap();
225/// let asinfo = AsInfoBuilder::new()
226///     .with_irr_sources(config)
227///     .build()
228///     .unwrap();
229/// ```
230#[derive(Debug, Clone, Default)]
231pub struct IrrSourceConfig {
232    /// Registry names to fetch. Empty is reserved for [`Self::all`].
233    sources: Vec<String>,
234}
235
236impl IrrSourceConfig {
237    /// Compatibility alias for [`Self::only`].
238    pub fn sources(names: &[&str]) -> Result<Self> {
239        Self::only(names)
240    }
241
242    /// Create a config that fetches exactly the named sources.
243    pub fn only(names: &[&str]) -> Result<Self> {
244        if names.is_empty() {
245            return Err(BgpkitCommonsError::invalid_format(
246                "IRR source selection",
247                "[]",
248                "explicit source selection must not be empty",
249            ));
250        }
251        let selected = crate::irr::sources_by_name(names)?;
252        Ok(Self {
253            sources: selected
254                .into_iter()
255                .map(|source| source.name.to_string())
256                .collect(),
257        })
258    }
259
260    /// Create a config that fetches every catalogued source.
261    pub fn all() -> Self {
262        Self {
263            sources: Vec::new(),
264        }
265    }
266
267    /// Resolve to the actual list of `IrrSource` structs to fetch.
268    fn resolve(&self) -> Result<Vec<crate::irr::IrrSource>> {
269        if self.sources.is_empty() {
270            Ok(crate::irr::all_sources())
271        } else {
272            let names = self.sources.iter().map(String::as_str).collect::<Vec<_>>();
273            crate::irr::sources_by_name(&names)
274        }
275    }
276}
277
278/// Loading profile for AS information data sources.
279///
280/// Controls which data sources are loaded. Each profile is a curated preset;
281/// use [`AsInfoBuilder`] directly for fine-grained control beyond these.
282///
283/// # Profiles
284///
285/// | Profile | Sources | Load time | Output size |
286/// |---------|---------|-----------|-------------|
287/// | [`Minimum`](AsInfoProfile::Minimum) | `asn.txt` only | ~1s | ~37 MB JSONL |
288/// | [`Default`](AsInfoProfile::Default) | asn.txt + as2org + population + hegemony + peeringdb | ~30s | ~50 MB JSONL |
289/// | [`Full`](AsInfoProfile::Full) | everything: + delegated stats + IRR (all sources) + route prefixes | ~75s | ~210 MB JSONL |
290///
291/// # Example
292///
293/// ```rust,no_run
294/// use bgpkit_commons::asinfo::AsInfoProfile;
295/// use bgpkit_commons::BgpkitCommons;
296///
297/// let mut commons = BgpkitCommons::new();
298/// commons.load_asinfo_with_profile(AsInfoProfile::Full).unwrap();
299/// ```
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
301pub enum AsInfoProfile {
302    /// Core `asn.txt` only: AS names + countries. Fast (~1s), minimal data.
303    Minimum,
304
305    /// Production default: asn.txt + as2org + population + hegemony + peeringdb.
306    /// Matches the current asninfo generator output.
307    #[default]
308    Default,
309
310    /// Everything: all of Default + delegated stats + IRR data from every
311    /// catalogued source, including route prefix lists.
312    Full,
313}
314
315impl AsInfoProfile {
316    /// Convert this profile into a builder configuration.
317    pub fn builder(self) -> AsInfoBuilder {
318        match self {
319            AsInfoProfile::Minimum => AsInfoBuilder::new(),
320            AsInfoProfile::Default => AsInfoBuilder::new()
321                .with_as2org()
322                .with_population()
323                .with_hegemony()
324                .with_peeringdb(),
325            AsInfoProfile::Full => AsInfoBuilder::new()
326                .with_as2org()
327                .with_population()
328                .with_hegemony()
329                .with_peeringdb()
330                .with_delegated()
331                .with_irr()
332                .with_irr_route_prefixes(),
333        }
334    }
335}
336
337/// Builder for configuring which data sources to load for AS information.
338///
339/// This is the canonical way to configure AS info loading. All data sources
340/// are opt-in — the core `asn.txt` name/country data always loads; everything
341/// else is gated behind a builder method.
342///
343/// # Example
344///
345/// ```rust,no_run
346/// use bgpkit_commons::asinfo::AsInfoBuilder;
347///
348/// let asinfo = AsInfoBuilder::new()
349///     .with_delegated()
350///     .with_irr()
351///     .with_as2org()
352///     .with_peeringdb()
353///     .build()
354///     .unwrap();
355/// ```
356///
357/// Selecting specific IRR sources only:
358///
359/// ```rust,no_run
360/// use bgpkit_commons::asinfo::AsInfoBuilder;
361/// use bgpkit_commons::asinfo::IrrSourceConfig;
362///
363/// let asinfo = AsInfoBuilder::new()
364///     .with_irr_sources(IrrSourceConfig::only(&["RIPE", "RADB"]).unwrap())
365///     .build()
366///     .unwrap();
367/// ```
368#[derive(Default)]
369pub struct AsInfoBuilder {
370    load_as2org: bool,
371    load_population: bool,
372    load_hegemony: bool,
373    load_peeringdb: bool,
374    load_delegated: bool,
375    load_irr: bool,
376    irr_config: IrrSourceConfig,
377    irr_route_prefixes: bool,
378}
379
380impl AsInfoBuilder {
381    /// Create a new builder with all data sources disabled by default.
382    pub fn new() -> Self {
383        Self::default()
384    }
385
386    /// Enable loading CAIDA AS-to-Organization mapping data.
387    pub fn with_as2org(mut self) -> Self {
388        self.load_as2org = true;
389        self
390    }
391
392    /// Enable loading APNIC AS population data.
393    pub fn with_population(mut self) -> Self {
394        self.load_population = true;
395        self
396    }
397
398    /// Enable loading IIJ IHR hegemony score data.
399    pub fn with_hegemony(mut self) -> Self {
400        self.load_hegemony = true;
401        self
402    }
403
404    /// Enable loading PeeringDB data.
405    pub fn with_peeringdb(mut self) -> Self {
406        self.load_peeringdb = true;
407        self
408    }
409
410    /// Enable loading RIR delegated-stats data (registry, country, date, status
411    /// per ASN from five RIR delegated stats files).
412    pub fn with_delegated(mut self) -> Self {
413        self.load_delegated = true;
414        self
415    }
416
417    /// Enable loading IRR data using every source in the IRR catalog.
418    pub fn with_irr(mut self) -> Self {
419        self.load_irr = true;
420        self
421    }
422
423    /// Enable loading IRR data with a custom set of sources.
424    ///
425    /// # Example
426    ///
427    /// ```rust,no_run
428    /// use bgpkit_commons::asinfo::{AsInfoBuilder, IrrSourceConfig};
429    ///
430    /// let asinfo = AsInfoBuilder::new()
431    ///     .with_irr_sources(IrrSourceConfig::only(&["RIPE", "RADB"]).unwrap())
432    ///     .build()
433    ///     .unwrap();
434    /// ```
435    pub fn with_irr_sources(mut self, config: IrrSourceConfig) -> Self {
436        self.load_irr = true;
437        self.irr_config = config;
438        self
439    }
440
441    /// Enable collecting IRR route/route6 prefix lists per ASN.
442    ///
443    /// Off by default — prefix lists are the largest data component
444    /// (~90MB JSON for all sources). Only enable when you need the
445    /// actual registered prefixes, not just AS names/metadata.
446    pub fn with_irr_route_prefixes(mut self) -> Self {
447        self.irr_route_prefixes = true;
448        self
449    }
450
451    /// Enable all optional data, including route prefixes from every IRR source.
452    pub fn with_all(mut self) -> Self {
453        self.load_as2org = true;
454        self.load_population = true;
455        self.load_hegemony = true;
456        self.load_peeringdb = true;
457        self.load_delegated = true;
458        self.load_irr = true;
459        self.irr_config = IrrSourceConfig::all();
460        self.irr_route_prefixes = true;
461        self
462    }
463
464    /// Build the AsInfoUtils with the configured data sources.
465    pub fn build(self) -> Result<AsInfoUtils> {
466        AsInfoUtils::from_builder(&self)
467    }
468
469    /// Internal: expose config for AsInfoUtils construction.
470    fn config(&self) -> Result<AsInfoLoadConfig> {
471        Ok(AsInfoLoadConfig {
472            load_as2org: self.load_as2org,
473            load_population: self.load_population,
474            load_hegemony: self.load_hegemony,
475            load_peeringdb: self.load_peeringdb,
476            load_delegated: self.load_delegated,
477            load_irr: self.load_irr,
478            irr_sources: self.irr_config.resolve()?,
479            irr_route_prefixes: self.irr_route_prefixes,
480        })
481    }
482}
483
484/// Internal configuration extracted from the builder.
485#[derive(Debug, Clone)]
486struct AsInfoLoadConfig {
487    load_as2org: bool,
488    load_population: bool,
489    load_hegemony: bool,
490    load_peeringdb: bool,
491    load_delegated: bool,
492    load_irr: bool,
493    irr_sources: Vec<crate::irr::IrrSource>,
494    irr_route_prefixes: bool,
495}
496
497pub struct AsInfoUtils {
498    pub asinfo_map: HashMap<u32, AsInfo>,
499    pub sibling_orgs: Option<SiblingOrgsUtils>,
500    config: AsInfoLoadConfig,
501}
502
503impl AsInfoUtils {
504    /// Build from a builder (canonical path).
505    fn from_builder(builder: &AsInfoBuilder) -> Result<Self> {
506        let config = builder.config()?;
507        let asinfo_map = get_asinfo_map(&config)?;
508        let sibling_orgs = if config.load_as2org {
509            Some(SiblingOrgsUtils::new()?)
510        } else {
511            None
512        };
513        Ok(AsInfoUtils {
514            asinfo_map,
515            sibling_orgs,
516            config,
517        })
518    }
519
520    pub fn new_from_cached() -> Result<Self> {
521        let asinfo_map = get_asinfo_map_cached()?;
522        let sibling_orgs = Some(SiblingOrgsUtils::new()?);
523        Ok(AsInfoUtils {
524            asinfo_map,
525            sibling_orgs,
526            config: AsInfoLoadConfig {
527                load_as2org: true,
528                load_population: true,
529                load_hegemony: true,
530                load_peeringdb: true,
531                load_delegated: true,
532                load_irr: true,
533                irr_sources: crate::irr::all_sources(),
534                irr_route_prefixes: false,
535            },
536        })
537    }
538
539    pub fn reload(&mut self) -> Result<()> {
540        self.asinfo_map = get_asinfo_map(&self.config)?;
541        Ok(())
542    }
543
544    pub fn get(&self, asn: u32) -> Option<&AsInfo> {
545        self.asinfo_map.get(&asn)
546    }
547}
548
549impl LazyLoadable for AsInfoUtils {
550    fn reload(&mut self) -> Result<()> {
551        self.reload()
552    }
553
554    fn is_loaded(&self) -> bool {
555        !self.asinfo_map.is_empty()
556    }
557
558    fn loading_status(&self) -> &'static str {
559        if self.is_loaded() {
560            "ASInfo data loaded"
561        } else {
562            "ASInfo data not loaded"
563        }
564    }
565}
566
567pub fn get_asinfo_map_cached() -> Result<HashMap<u32, AsInfo>> {
568    info!("loading asinfo from previously generated BGPKIT cache file...");
569    let mut asnames_map = HashMap::new();
570    let reader = oneio::get_reader(BGPKIT_ASNINFO_URL)?;
571    for line in std::io::BufReader::new(reader).lines() {
572        let line = line?;
573        if line.trim().is_empty() {
574            continue;
575        }
576        let asinfo: AsInfo = serde_json::from_str(&line)?;
577        asnames_map.insert(asinfo.asn, asinfo);
578    }
579    Ok(asnames_map)
580}
581
582/// Project a source-faithful delegated-statistics record into AsInfo data.
583///
584/// Only `asn` records with `allocated`/`assigned` status and a real (non-empty,
585/// non-`*`) country code are kept; private-use ASN ranges (RFC 6996:
586/// 64512-65534 and 4200000000+) are excluded. Ranges are expanded per-ASN
587/// (`value` is a count).
588fn project_delegated_record(
589    record: crate::delegated::DelegatedRecord,
590    map: &mut HashMap<u32, DelegatedInfo>,
591) {
592    if record.record_type != "asn" {
593        return;
594    }
595    let status = record.status.trim();
596    if status != "allocated" && status != "assigned" {
597        return;
598    }
599    let cc = record.country.trim();
600    if cc.is_empty() || cc == "*" {
601        return;
602    }
603    let (Ok(start), Ok(count)) = (record.start.parse::<u64>(), record.value.parse::<u64>()) else {
604        return;
605    };
606    let registry = record.registry.trim().to_lowercase();
607    let country = cc.to_uppercase();
608    let date = record.date.trim().to_string();
609    for asn in start..start.saturating_add(count) {
610        if asn > u32::MAX as u64 {
611            break;
612        }
613        let asn = asn as u32;
614        if (64512..=65534).contains(&asn) || asn >= 4_200_000_000 {
615            continue;
616        }
617        map.entry(asn).or_insert(DelegatedInfo {
618            registry: registry.clone(),
619            country: country.clone(),
620            date: date.clone(),
621            status: status.to_string(),
622        });
623    }
624}
625
626#[cfg(test)]
627fn project_delegated_stats(text: &str, map: &mut HashMap<u32, DelegatedInfo>) {
628    for record in crate::delegated::parse_reader(text.as_bytes()).flatten() {
629        project_delegated_record(record, map);
630    }
631}
632
633/// Look up optional enrichment data (as2org, population, hegemony, peeringdb)
634/// for an ASN from already-loaded datasets. Shared by the main `asn.txt` parse
635/// loop and the delegated-stats fill so both paths behave identically.
636#[allow(clippy::type_complexity)]
637fn lookup_enrichment(
638    asn: u32,
639    as2org_utils: Option<&as2org::As2org>,
640    population_utils: Option<&population::AsnPopulation>,
641    hegemony_utils: Option<&hegemony::Hegemony>,
642    peeringdb_utils: Option<&Peeringdb>,
643) -> (
644    Option<As2orgInfo>,
645    Option<AsnPopulationData>,
646    Option<HegemonyData>,
647    Option<Network>,
648) {
649    let as2org = as2org_utils.and_then(|as2org_data| {
650        as2org_data.get_as_info(asn).map(|info| As2orgInfo {
651            name: info.name.clone(),
652            country: info.country_code.clone(),
653            org_id: info.org_id.clone(),
654            org_name: info.org_name.clone(),
655        })
656    });
657    let population = population_utils.and_then(|p| p.get(asn));
658    let hegemony = hegemony_utils.and_then(|h| h.get_score(asn).cloned());
659    let peeringdb = peeringdb_utils.and_then(|h| h.get_network(asn).cloned());
660    (as2org, population, hegemony, peeringdb)
661}
662
663/// Load RIR delegated stats and attach [`DelegatedInfo`] to every ASN in the
664/// map. For ASNs missing from `asn.txt`, new entries are created with
665/// `name: "UNKNOWN"` and the delegated country code.
666///
667/// Delegated stats are authoritative allocation records updated daily, covering
668/// newly-allocated ASNs that `asn.txt` lags on by days to weeks. Every ASN
669/// (not just gap ASNs) gets structured delegated data attached.
670///
671/// Best-effort: failures fetching individual files are logged and skipped.
672fn fill_delegated_data(
673    asnames_map: &mut HashMap<u32, AsInfo>,
674    as2org_utils: Option<&as2org::As2org>,
675    population_utils: Option<&population::AsnPopulation>,
676    hegemony_utils: Option<&hegemony::Hegemony>,
677    peeringdb_utils: Option<&Peeringdb>,
678) {
679    let mut delegated: HashMap<u32, DelegatedInfo> = HashMap::new();
680    for url in crate::delegated::RIR_DELEGATED_STATS_URLS {
681        match crate::delegated::fetch(url) {
682            Ok(reader) => {
683                for record in crate::delegated::parse_reader(reader) {
684                    match record {
685                        Ok(record) => project_delegated_record(record, &mut delegated),
686                        Err(e) => warn!("failed to parse delegated stats from {url}: {e}"),
687                    }
688                }
689            }
690            Err(e) => warn!("failed to load delegated stats from {}: {}", url, e),
691        }
692    }
693    attach_delegated_data(
694        asnames_map,
695        delegated,
696        as2org_utils,
697        population_utils,
698        hegemony_utils,
699        peeringdb_utils,
700    );
701}
702
703/// Attach per-ASN [`DelegatedInfo`] values to the map, creating `AsInfo`
704/// entries for ASNs absent from `asn.txt` (with `name: "UNKNOWN"` and the
705/// delegated country as the base country).
706///
707/// Only the `delegated` field of existing entries is modified; the base `name`
708/// and `country` fields are never overwritten. When the same ASN appears in
709/// multiple RIR files (possible during inter-RIR transfers), the first
710/// [`DelegatedInfo`] encountered wins; file order is the order of
711/// [`crate::delegated::RIR_DELEGATED_STATS_URLS`].
712fn attach_delegated_data(
713    asnames_map: &mut HashMap<u32, AsInfo>,
714    delegated: HashMap<u32, DelegatedInfo>,
715    as2org_utils: Option<&as2org::As2org>,
716    population_utils: Option<&population::AsnPopulation>,
717    hegemony_utils: Option<&hegemony::Hegemony>,
718    peeringdb_utils: Option<&Peeringdb>,
719) {
720    let mut new_entries = 0usize;
721    let mut attached = 0usize;
722    for (asn, delegated_info) in delegated {
723        asnames_map
724            .entry(asn)
725            .and_modify(|info| {
726                info.delegated = Some(delegated_info.clone());
727                attached += 1;
728            })
729            .or_insert_with(|| {
730                new_entries += 1;
731                let (as2org, population, hegemony, peeringdb) = lookup_enrichment(
732                    asn,
733                    as2org_utils,
734                    population_utils,
735                    hegemony_utils,
736                    peeringdb_utils,
737                );
738                AsInfo {
739                    asn,
740                    name: "UNKNOWN".to_string(),
741                    country: delegated_info.country.clone(),
742                    as2org,
743                    population,
744                    hegemony,
745                    peeringdb,
746                    delegated: Some(delegated_info.clone()),
747                    irr: Vec::new(),
748                }
749            });
750    }
751    info!(
752        "delegated stats: {attached} existing entries enriched, {new_entries} new entries created"
753    );
754}
755/// Enrich AsInfo entries with structured IRR data from selected sources.
756///
757/// For each IRR source (RIPE, APNIC, ARIN, LACNIC, AFRINIC, NTTCOM, RADB),
758/// collects:
759/// - `aut-num` objects → `as-name`, `descr`, `mnt-by`
760/// - `route` objects → registered IPv4 prefixes per ASN
761/// - `route6` objects → registered IPv6 prefixes per ASN
762/// - `as-set` objects → reverse membership (which sets contain this ASN)
763///
764/// Each source produces an [`IrrAsnInfo`] entry in the `irr` Vec, so callers
765/// can pick which source(s) to trust. Per-source failures are logged and
766/// skipped.
767///
768fn enrich_from_irr(
769    asnames_map: &mut HashMap<u32, AsInfo>,
770    irr_sources: &[crate::irr::IrrSource],
771    collect_route_prefixes: bool,
772) {
773    use crate::irr::sources::DumpFormat;
774    use crate::irr::types::{IrrObject, IrrObjectType};
775    use std::collections::HashMap as StdMap;
776
777    // Per-source accumulator: source_name -> (asn -> IrrAsnInfo builder)
778    let mut per_source: StdMap<String, StdMap<u32, IrrAsnInfoBuilder>> = StdMap::new();
779
780    // Track which dump URLs we've already parsed (whole-DB files serve all types).
781    let mut parsed_urls: std::collections::HashSet<String> = std::collections::HashSet::new();
782
783    let wanted_types: Vec<IrrObjectType> = if collect_route_prefixes {
784        vec![
785            IrrObjectType::AutNum,
786            IrrObjectType::Route,
787            IrrObjectType::Route6,
788            IrrObjectType::AsSet,
789        ]
790    } else {
791        // Without route prefixes: only aut-num + as-set.
792        // For WholeDb sources this means we still download once but skip route objects.
793        // For SplitFile sources we skip the route/route6 files entirely.
794        vec![IrrObjectType::AutNum, IrrObjectType::AsSet]
795    };
796
797    for source in irr_sources.iter().cloned() {
798        let source_name = source.name.to_string();
799
800        // For each source, figure out the unique URLs to download.
801        // SplitFile sources have one URL per type; WholeDb has a single URL
802        // that we parse once and extract all types.
803        let mut urls_to_parse: Vec<(String, Vec<IrrObjectType>)> = Vec::new();
804
805        if source.format == DumpFormat::WholeDb {
806            // Single URL, parse once for all types
807            let url = source.dump_urls(IrrObjectType::AutNum);
808            if let Some(dump) = url.first() {
809                urls_to_parse.push((dump.url.clone(), wanted_types.to_vec()));
810            }
811        } else {
812            // Split files: one URL per type
813            for obj_type in &wanted_types {
814                for dump in source.dump_urls(*obj_type) {
815                    urls_to_parse.push((dump.url.clone(), vec![*obj_type]));
816                }
817            }
818        }
819
820        for (url, _types_for_url) in urls_to_parse {
821            if parsed_urls.contains(&url) {
822                continue;
823            }
824            parsed_urls.insert(url.clone());
825
826            let sn = source_name.clone();
827
828            match crate::irr::parse_dump(
829                &crate::irr::IrrDumpUrl {
830                    url: url.clone(),
831                    transport: source.transport,
832                    format: source.format,
833                },
834                |obj| {
835                    let source_map = per_source.entry(sn.clone()).or_default();
836                    match &obj {
837                        IrrObject::AutNum(a) => {
838                            let entry = source_map.entry(a.asn).or_default();
839                            entry.source = a.source.clone();
840                            entry.as_name = a.as_name.clone();
841                            entry.descr = a.descr.clone();
842                            if let Some(mnt) = a.extra.get("mnt-by") {
843                                entry.mnt_by = mnt.clone();
844                            }
845                        }
846                        // Route/route6 prefixes are collected only when
847                        // explicitly enabled. WholeDb dumps still download once
848                        // (URL dedup above) but route objects are skipped here
849                        // in the default no-prefix mode.
850                        IrrObject::Route(r) if collect_route_prefixes => {
851                            let entry = source_map.entry(r.origin).or_default();
852                            if entry.source.is_empty() {
853                                entry.source = r.source.clone();
854                            }
855                            if let IpNet::V4(prefix) = r.prefix {
856                                entry.route_prefixes.push(prefix);
857                            }
858                        }
859                        IrrObject::Route6(r) if collect_route_prefixes => {
860                            let entry = source_map.entry(r.origin).or_default();
861                            if entry.source.is_empty() {
862                                entry.source = r.source.clone();
863                            }
864                            if let IpNet::V6(prefix) = r.prefix {
865                                entry.route6_prefixes.push(prefix);
866                            }
867                        }
868                        IrrObject::AsSet(s) => {
869                            let set_name = s.name.clone();
870                            for &member_asn in &s.members {
871                                let entry = source_map.entry(member_asn).or_default();
872                                if entry.source.is_empty() {
873                                    entry.source = s.source.clone();
874                                }
875                                entry.member_of_sets.push(set_name.clone());
876                            }
877                        }
878                        _ => {}
879                    }
880                },
881            ) {
882                Ok(stats) => info!(
883                    "IRR from {source_name} ({url}): {} objects extracted",
884                    stats.extracted
885                ),
886                Err(e) => warn!("failed to load IRR from {source_name} ({url}): {e}"),
887            }
888        }
889    }
890
891    attach_irr_data(asnames_map, per_source, irr_sources);
892}
893
894/// Attach per-source [`IrrAsnInfo`] values to each ASN.
895///
896/// Only the `irr` field of existing entries is modified; the base `name` and
897/// `country` fields are never overwritten. Entries are produced in the order
898/// of `irr_sources`, one per registry that has any data for the ASN.
899fn attach_irr_data(
900    asnames_map: &mut HashMap<u32, AsInfo>,
901    per_source: std::collections::HashMap<
902        String,
903        std::collections::HashMap<u32, IrrAsnInfoBuilder>,
904    >,
905    irr_sources: &[crate::irr::IrrSource],
906) {
907    let mut irr_attached = 0usize;
908
909    for (asn, info) in asnames_map.iter_mut() {
910        let mut irr_entries: Vec<IrrAsnInfo> = Vec::new();
911
912        for source in irr_sources.iter().cloned() {
913            if let Some(source_map) = per_source.get(source.name) {
914                if let Some(builder) = source_map.get(asn) {
915                    irr_entries.push(builder.clone().build());
916                }
917            }
918        }
919
920        if !irr_entries.is_empty() {
921            info.irr = irr_entries;
922            irr_attached += 1;
923        }
924    }
925
926    info!("IRR data attached to {irr_attached} ASNs");
927}
928
929/// Builder for IrrAsnInfo — accumulates data from multiple object types
930/// (aut-num, route, route6, as-set) before producing the final struct.
931#[derive(Debug, Clone, Default)]
932struct IrrAsnInfoBuilder {
933    as_name: String,
934    descr: Vec<String>,
935    source: String,
936    mnt_by: Vec<String>,
937    route_prefixes: Vec<Ipv4Net>,
938    route6_prefixes: Vec<Ipv6Net>,
939    member_of_sets: Vec<String>,
940}
941
942impl IrrAsnInfoBuilder {
943    fn build(self) -> IrrAsnInfo {
944        IrrAsnInfo {
945            as_name: self.as_name,
946            descr: self.descr,
947            source: self.source,
948            mnt_by: self.mnt_by,
949            route_prefixes: self.route_prefixes,
950            route6_prefixes: self.route6_prefixes,
951            member_of_sets: self.member_of_sets,
952        }
953    }
954}
955
956/// Loads the ASN information map and returns it.
957///
958/// The core RIPE NCC `asn.txt` data (plus the RIR delegated-stats fill) is
959/// required: load failures propagate as `Err`. Optional enrichment datasets
960/// (as2org, population, hegemony, peeringdb) fail soft — a failed download or
961/// API error (e.g., PeeringDB rate limiting without `PEERINGDB_API_KEY`)
962/// logs a warning and proceeds with that dataset's fields left as `None`.
963/// Loads the ASN information map and returns it.
964///
965/// The core RIPE NCC `asn.txt` data (plus the RIR delegated-stats fill) is
966/// required: load failures propagate as `Err`. Optional enrichment datasets
967/// (as2org, population, hegemony, peeringdb) fail soft — a failed download or
968/// API error (e.g., PeeringDB rate limiting without `PEERINGDB_API_KEY`)
969/// logs a warning and proceeds with that dataset's fields left as `None`.
970fn get_asinfo_map(config: &AsInfoLoadConfig) -> Result<HashMap<u32, AsInfo>> {
971    let load_as2org = config.load_as2org;
972    let load_population = config.load_population;
973    let load_hegemony = config.load_hegemony;
974    let load_peeringdb = config.load_peeringdb;
975    let read_text = |url: &str| -> Result<String> {
976        let mut text = String::new();
977        oneio::get_reader(url)?.read_to_string(&mut text)?;
978        Ok(text)
979    };
980    let text = match read_text(BGPKIT_ASN_TXT_MIRROR_URL) {
981        Ok(t) => t,
982        Err(_) => match read_text(RIPE_RIS_ASN_TXT_URL) {
983            Ok(t) => t,
984            Err(e) => {
985                return Err(BgpkitCommonsError::data_source_error(
986                    data_sources::BGPKIT,
987                    format!(
988                        "error reading asinfo (neither mirror or original works): {}",
989                        e
990                    ),
991                ));
992            }
993        },
994    };
995
996    let as2org_utils = if load_as2org {
997        info!("loading as2org data from CAIDA...");
998        match as2org::As2org::new(None) {
999            Ok(data) => Some(data),
1000            Err(e) => {
1001                warn!("failed to load as2org data, proceeding without it: {e}");
1002                None
1003            }
1004        }
1005    } else {
1006        None
1007    };
1008    let population_utils = if load_population {
1009        info!("loading ASN population data from APNIC...");
1010        match population::AsnPopulation::new() {
1011            Ok(data) => Some(data),
1012            Err(e) => {
1013                warn!("failed to load population data, proceeding without it: {e}");
1014                None
1015            }
1016        }
1017    } else {
1018        None
1019    };
1020    let hegemony_utils = if load_hegemony {
1021        info!("loading IIJ IHR hegemony score data from BGPKIT mirror...");
1022        match hegemony::Hegemony::new() {
1023            Ok(data) => Some(data),
1024            Err(e) => {
1025                warn!("failed to load hegemony data, proceeding without it: {e}");
1026                None
1027            }
1028        }
1029    } else {
1030        None
1031    };
1032    let peeringdb_utils = if load_peeringdb {
1033        info!("loading peeringdb data...");
1034        match Peeringdb::new_networks_only() {
1035            Ok(data) => Some(data),
1036            Err(e) => {
1037                warn!(
1038                    "failed to load peeringdb data, proceeding without it: {e} \
1039                     (hint: set PEERINGDB_API_KEY to avoid rate limiting)"
1040                );
1041                None
1042            }
1043        }
1044    } else {
1045        None
1046    };
1047
1048    let asnames = text
1049        .lines()
1050        .filter_map(|line| {
1051            let (asn_str, name_country_str) = match line.split_once(' ') {
1052                Some((asn, name)) => (asn, name),
1053                None => return None,
1054            };
1055            let (name_str, country_str) = match name_country_str.rsplit_once(", ") {
1056                Some((name, country)) => (name, country),
1057                None => return None,
1058            };
1059            let asn = asn_str.parse::<u32>().unwrap();
1060            let (as2org, population, hegemony, peeringdb) = lookup_enrichment(
1061                asn,
1062                as2org_utils.as_ref(),
1063                population_utils.as_ref(),
1064                hegemony_utils.as_ref(),
1065                peeringdb_utils.as_ref(),
1066            );
1067            Some(AsInfo {
1068                asn,
1069                name: name_str.to_string(),
1070                country: country_str.to_string(),
1071                as2org,
1072                population,
1073                hegemony,
1074                peeringdb,
1075                delegated: None,
1076                irr: Vec::new(),
1077            })
1078        })
1079        .collect::<Vec<AsInfo>>();
1080
1081    let mut asnames_map = HashMap::new();
1082    for asname in asnames {
1083        asnames_map.insert(asname.asn, asname);
1084    }
1085
1086    if config.load_delegated {
1087        info!("loading delegated stats data...");
1088        fill_delegated_data(
1089            &mut asnames_map,
1090            as2org_utils.as_ref(),
1091            population_utils.as_ref(),
1092            hegemony_utils.as_ref(),
1093            peeringdb_utils.as_ref(),
1094        );
1095    }
1096
1097    if config.load_irr {
1098        info!("enriching from IRR data...");
1099        enrich_from_irr(
1100            &mut asnames_map,
1101            &config.irr_sources,
1102            config.irr_route_prefixes,
1103        );
1104    }
1105
1106    Ok(asnames_map)
1107}
1108
1109impl BgpkitCommons {
1110    /// Returns a HashMap containing all AS information.
1111    ///
1112    /// # Returns
1113    ///
1114    /// - `Ok(HashMap<u32, AsInfo>)`: A HashMap where the key is the ASN and the value is the corresponding AsInfo.
1115    /// - `Err`: If the asinfo is not loaded.
1116    ///
1117    /// # Examples
1118    ///
1119    /// ```no_run
1120    /// use bgpkit_commons::BgpkitCommons;
1121    ///
1122    /// let mut bgpkit = BgpkitCommons::new();
1123    /// bgpkit.load_asinfo_with_profile(Default::default()).unwrap();
1124    /// let all_asinfo = bgpkit.asinfo_all().unwrap();
1125    /// ```
1126    pub fn asinfo_all(&self) -> Result<HashMap<u32, AsInfo>> {
1127        if self.asinfo.is_none() {
1128            return Err(BgpkitCommonsError::module_not_loaded(
1129                modules::ASINFO,
1130                load_methods::LOAD_ASINFO,
1131            ));
1132        }
1133
1134        Ok(self.asinfo.as_ref().unwrap().asinfo_map.clone())
1135    }
1136
1137    /// Retrieves AS information for a specific ASN.
1138    ///
1139    /// # Arguments
1140    ///
1141    /// * `asn` - The Autonomous System Number to look up.
1142    ///
1143    /// # Returns
1144    ///
1145    /// - `Ok(Some(AsInfo))`: The AS information if found.
1146    /// - `Ok(None)`: If the ASN is not found in the database.
1147    /// - `Err`: If the asinfo is not loaded.
1148    ///
1149    /// # Examples
1150    ///
1151    /// ```no_run
1152    /// use bgpkit_commons::BgpkitCommons;
1153    ///
1154    /// let mut bgpkit = BgpkitCommons::new();
1155    /// bgpkit.load_asinfo_with_profile(Default::default()).unwrap();
1156    /// let asinfo = bgpkit.asinfo_get(3333).unwrap();
1157    /// ```
1158    pub fn asinfo_get(&self, asn: u32) -> Result<Option<AsInfo>> {
1159        if self.asinfo.is_none() {
1160            return Err(BgpkitCommonsError::module_not_loaded(
1161                modules::ASINFO,
1162                load_methods::LOAD_ASINFO,
1163            ));
1164        }
1165
1166        Ok(self.asinfo.as_ref().unwrap().get(asn).cloned())
1167    }
1168
1169    /// Checks if two ASNs are siblings (belong to the same organization).
1170    ///
1171    /// # Arguments
1172    ///
1173    /// * `asn1` - The first Autonomous System Number.
1174    /// * `asn2` - The second Autonomous System Number.
1175    ///
1176    /// # Returns
1177    ///
1178    /// - `Ok(bool)`: True if the ASNs are siblings, false otherwise.
1179    /// - `Err`: If the asinfo is not loaded or not loaded with as2org data.
1180    ///
1181    /// # Examples
1182    ///
1183    /// ```no_run
1184    /// use bgpkit_commons::BgpkitCommons;
1185    ///
1186    /// let mut bgpkit = BgpkitCommons::new();
1187    /// bgpkit.load_asinfo_with(bgpkit.asinfo_builder().with_as2org()).unwrap();
1188    /// let are_siblings = bgpkit.asinfo_are_siblings(3333, 3334).unwrap();
1189    /// ```
1190    ///
1191    /// # Note
1192    ///
1193    /// This function requires the asinfo to be loaded with as2org data.
1194    pub fn asinfo_are_siblings(&self, asn1: u32, asn2: u32) -> Result<bool> {
1195        if self.asinfo.is_none() {
1196            return Err(BgpkitCommonsError::module_not_loaded(
1197                modules::ASINFO,
1198                load_methods::LOAD_ASINFO,
1199            ));
1200        }
1201        if !self.asinfo.as_ref().unwrap().config.load_as2org {
1202            return Err(BgpkitCommonsError::module_not_configured(
1203                modules::ASINFO,
1204                "as2org data",
1205                "load_asinfo() with as2org=true",
1206            ));
1207        }
1208
1209        let info_1_opt = self.asinfo_get(asn1)?;
1210        let info_2_opt = self.asinfo_get(asn2)?;
1211
1212        if let (Some(info1), Some(info2)) = (info_1_opt, info_2_opt) {
1213            if let (Some(org1), Some(org2)) = (info1.as2org, info2.as2org) {
1214                let org_id_1 = org1.org_id;
1215                let org_id_2 = org2.org_id;
1216
1217                return Ok(org_id_1 == org_id_2
1218                    || self
1219                        .asinfo
1220                        .as_ref()
1221                        .and_then(|a| a.sibling_orgs.as_ref())
1222                        .map(|s| s.are_sibling_orgs(org_id_1.as_str(), org_id_2.as_str()))
1223                        .unwrap_or(false));
1224            }
1225        }
1226        Ok(false)
1227    }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    /// Helper: check country from DelegatedInfo in map.
1235    fn cc(map: &HashMap<u32, DelegatedInfo>, asn: u32) -> Option<&str> {
1236        map.get(&asn).map(|d| d.country.as_str())
1237    }
1238
1239    #[test]
1240    fn test_parse_delegated_stats_basic() {
1241        let text = "\
12422|ripencc|ZZ|209|20250704|00000000+00000000+00000000|UTF-8
1243ripencc|*|asn|*|39634|summary
1244ripencc|GB|asn|219157|1|20260722|allocated
1245ripencc|DE|asn|219125|1|20260728|allocated
1246arin||asn|212|1||reserved|
1247arin|*|asn|*|32843|summary
1248arin|US|asn|402598|1|20260604|assigned|
1249apnic|BD|asn|154708|1|20260609|allocated
1250ripencc|NL|asn|1000|4|19970901|allocated
1251ripencc|NL|ipv4|185.0.0.0|65536|20000101|allocated
1252";
1253        let mut map = HashMap::new();
1254        project_delegated_stats(text, &mut map);
1255        assert_eq!(cc(&map, 219157), Some("GB"));
1256        assert_eq!(cc(&map, 219125), Some("DE"));
1257        assert_eq!(cc(&map, 402598), Some("US"));
1258        assert_eq!(cc(&map, 154708), Some("BD"));
1259        // range expansion: AS1000..=AS1003 (value is a count of 4)
1260        assert_eq!(cc(&map, 1000), Some("NL"));
1261        assert_eq!(cc(&map, 1003), Some("NL"));
1262        assert!(!map.contains_key(&1004));
1263        // reserved entries with empty CC are skipped
1264        assert!(!map.contains_key(&212));
1265        // non-asn records are skipped
1266        assert_eq!(map.len(), 8);
1267        // Verify structured fields
1268        let info = &map[&219157];
1269        assert_eq!(info.registry, "ripencc");
1270        assert_eq!(info.status, "allocated");
1271        assert_eq!(info.date, "20260722");
1272    }
1273
1274    #[test]
1275    fn test_parse_delegated_stats_skips_private_and_invalid() {
1276        let text = "\
1277arin|US|asn|64512|1023|19891201|reserved
1278arin|US|asn|4200000000|9999|19891201|reserved
1279arin|US|asn|notanumber|1|20200101|allocated
1280arin|US|asn|123|notacount|20200101|allocated
1281";
1282        let mut map = HashMap::new();
1283        project_delegated_stats(text, &mut map);
1284        assert!(map.is_empty());
1285    }
1286
1287    #[test]
1288    fn test_parse_delegated_stats_status_filter() {
1289        // reserved/available records are dropped even when they carry a
1290        // real-looking country code; only allocated/assigned are kept
1291        let text = "\
1292arin|US|asn|300000|1|20200101|reserved
1293arin|US|asn|300001|1|20200101|available
1294arin|US|asn|300002|1|20200101|allocated
1295arin|US|asn|300003|1|20200101|assigned
1296";
1297        let mut map = HashMap::new();
1298        project_delegated_stats(text, &mut map);
1299        assert!(!map.contains_key(&300000));
1300        assert!(!map.contains_key(&300001));
1301        assert_eq!(cc(&map, 300002), Some("US"));
1302        assert_eq!(cc(&map, 300003), Some("US"));
1303        assert_eq!(map.len(), 2);
1304    }
1305
1306    #[test]
1307    fn test_parse_delegated_stats_private_boundary() {
1308        // AS65535 (last private 16-bit ASN, not in 64512..=65534) is kept;
1309        // AS65534 is dropped. RFC 6996 documentation ASN 64496 is public but
1310        // unused; it is kept since only the exact private ranges are filtered.
1311        let text = "\
1312arin|US|asn|65535|1|19891201|allocated
1313arin|US|asn|65534|1|19891201|allocated
1314arin|US|asn|64496|1|19891201|allocated
1315arin|US|asn|4199999999|1|19891201|allocated
1316arin|US|asn|4200000000|1|19891201|allocated
1317";
1318        let mut map = HashMap::new();
1319        project_delegated_stats(text, &mut map);
1320        assert_eq!(cc(&map, 65535), Some("US"));
1321        assert!(!map.contains_key(&65534));
1322        assert_eq!(cc(&map, 64496), Some("US"));
1323        assert_eq!(cc(&map, 4199999999), Some("US"));
1324        assert!(!map.contains_key(&4200000000));
1325    }
1326
1327    #[test]
1328    fn test_parse_delegated_stats_case_normalization() {
1329        let text = "lacnic|br|asn|269000|1|20150101|allocated\n";
1330        let mut map = HashMap::new();
1331        project_delegated_stats(text, &mut map);
1332        assert_eq!(cc(&map, 269000), Some("BR"));
1333        assert_eq!(map[&269000].registry, "lacnic");
1334    }
1335
1336    #[test]
1337    fn test_parse_delegated_stats_malformed_lines() {
1338        let text = "\
1339# comment line
1340
1341ripencc|GB|asn
1342ripencc|GB|ipv6|2001:db8::|32|20200101|allocated
1343some garbage line with no pipes at all
1344|GB|asn|100|1|20200101|allocated
1345ripencc|GB|asn|100|1|20200101
1346ripencc|GB|asn|100|1|20200101|allocated|extra|fields|ok
1347";
1348        let mut map = HashMap::new();
1349        project_delegated_stats(text, &mut map);
1350        // empty registry is kept (only CC matters), short lines dropped,
1351        // extended lines with >7 fields still parsed
1352        assert_eq!(cc(&map, 100), Some("GB"));
1353        assert_eq!(map.len(), 1);
1354    }
1355
1356    #[test]
1357    fn test_profiles_match_asninfo_v1_and_full_uses_all_sources() {
1358        let minimum = AsInfoProfile::Minimum.builder().config().unwrap();
1359        assert!(!minimum.load_as2org);
1360        assert!(!minimum.load_population);
1361        assert!(!minimum.load_hegemony);
1362        assert!(!minimum.load_peeringdb);
1363        assert!(!minimum.load_delegated);
1364        assert!(!minimum.load_irr);
1365
1366        let default = AsInfoProfile::Default.builder().config().unwrap();
1367        assert!(default.load_as2org);
1368        assert!(default.load_population);
1369        assert!(default.load_hegemony);
1370        assert!(default.load_peeringdb);
1371        assert!(!default.load_delegated);
1372        assert!(!default.load_irr);
1373
1374        let full = AsInfoProfile::Full.builder().config().unwrap();
1375        assert!(full.load_delegated);
1376        assert!(full.load_irr);
1377        assert!(full.irr_route_prefixes);
1378        assert_eq!(full.irr_sources.len(), crate::irr::all_sources().len());
1379
1380        let all = AsInfoBuilder::new().with_all().config().unwrap();
1381        assert!(all.irr_route_prefixes);
1382        assert_eq!(all.irr_sources.len(), crate::irr::all_sources().len());
1383    }
1384
1385    #[test]
1386    fn test_custom_irr_sources_are_validated() {
1387        assert!(IrrSourceConfig::only(&[]).is_err());
1388        assert!(IrrSourceConfig::sources(&[]).is_err());
1389        assert!(IrrSourceConfig::sources(&["RIPE", "NOT-A-REGISTRY"]).is_err());
1390
1391        let selected = IrrSourceConfig::sources(&["RIPE", "RADB"]).unwrap();
1392        let config = AsInfoBuilder::new()
1393            .with_irr_sources(selected)
1394            .config()
1395            .unwrap();
1396        assert_eq!(
1397            config
1398                .irr_sources
1399                .iter()
1400                .map(|source| source.name)
1401                .collect::<Vec<_>>(),
1402            vec!["RIPE", "RADB"]
1403        );
1404    }
1405
1406    #[test]
1407    fn delegated_enrichment_never_overwrites_name_or_country() {
1408        let mut map = HashMap::new();
1409        map.insert(
1410            13335,
1411            AsInfo {
1412                asn: 13335,
1413                name: "CLOUDFLARENET".to_string(),
1414                country: "US".to_string(),
1415                as2org: None,
1416                population: None,
1417                hegemony: None,
1418                peeringdb: None,
1419                delegated: None,
1420                irr: Vec::new(),
1421            },
1422        );
1423
1424        let mut delegated = HashMap::new();
1425        delegated.insert(
1426            13335,
1427            DelegatedInfo {
1428                registry: "ripencc".to_string(),
1429                country: "GB".to_string(),
1430                date: "20260722".to_string(),
1431                status: "allocated".to_string(),
1432            },
1433        );
1434        // ASN missing from asn.txt: a new entry is created, not an overwrite.
1435        delegated.insert(
1436            400644,
1437            DelegatedInfo {
1438                registry: "arin".to_string(),
1439                country: "US".to_string(),
1440                date: "20200101".to_string(),
1441                status: "allocated".to_string(),
1442            },
1443        );
1444
1445        attach_delegated_data(&mut map, delegated, None, None, None, None);
1446
1447        // Existing entry: base fields untouched, delegated attached.
1448        let existing = &map[&13335];
1449        assert_eq!(existing.name, "CLOUDFLARENET");
1450        assert_eq!(existing.country, "US");
1451        assert_eq!(existing.delegated.as_ref().unwrap().registry, "ripencc");
1452
1453        // New entry: UNKNOWN name, delegated country as base country.
1454        let new_entry = &map[&400644];
1455        assert_eq!(new_entry.name, "UNKNOWN");
1456        assert_eq!(new_entry.country, "US");
1457        assert_eq!(new_entry.delegated.as_ref().unwrap().registry, "arin");
1458    }
1459
1460    #[test]
1461    fn irr_enrichment_never_overwrites_name_or_country() {
1462        let mut map = HashMap::new();
1463        map.insert(
1464            13335,
1465            AsInfo {
1466                asn: 13335,
1467                name: "CLOUDFLARENET".to_string(),
1468                country: "US".to_string(),
1469                as2org: None,
1470                population: None,
1471                hegemony: None,
1472                peeringdb: None,
1473                delegated: None,
1474                irr: Vec::new(),
1475            },
1476        );
1477
1478        // A single source with data for AS13335 (as_name disagrees with the
1479        // base name on purpose: IRR must not replace the base fields).
1480        let mut per_source: std::collections::HashMap<
1481            String,
1482            std::collections::HashMap<u32, IrrAsnInfoBuilder>,
1483        > = std::collections::HashMap::new();
1484        let mut builder = IrrAsnInfoBuilder::default();
1485        builder.source = "RIPE".to_string();
1486        builder.as_name = "CLOUDFLARE-NET".to_string();
1487        per_source.insert("RIPE".to_string(), [(13335, builder)].into_iter().collect());
1488
1489        let ripe = crate::irr::sources::all_sources()
1490            .into_iter()
1491            .find(|source| source.name == "RIPE")
1492            .unwrap();
1493        attach_irr_data(&mut map, per_source, &[ripe]);
1494
1495        let info = &map[&13335];
1496        assert_eq!(info.name, "CLOUDFLARENET");
1497        assert_eq!(info.country, "US");
1498        assert_eq!(info.irr.len(), 1);
1499        assert_eq!(info.irr[0].as_name, "CLOUDFLARE-NET");
1500        assert_eq!(info.irr[0].source, "RIPE");
1501    }
1502}