diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index c23ba5d..1929a33 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -10,18 +10,29 @@ env: CARGO_TERM_COLOR: always jobs: - build: - + format-and-docs: + name: Format and Documentation Checks runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - name: Run format check + run: cargo fmt --check - name: Run cargo-readme check run: cargo install cargo-readme && cargo readme > TMP_README.md && diff -b TMP_README.md README.md - - name: Run format check - run: cargo fmt --check + build-and-test: + name: Build, Test, and Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose - name: Run clippy run: cargo clippy --all-features -- -D warnings diff --git a/.github/workflows/scheduled-tests.yaml b/.github/workflows/scheduled-tests.yaml new file mode 100644 index 0000000..8444cb2 --- /dev/null +++ b/.github/workflows/scheduled-tests.yaml @@ -0,0 +1,47 @@ +name: Scheduled Tests + +on: + schedule: + # Run daily at 3:00 AM UTC + - cron: '0 3 * * *' + workflow_dispatch: # Allow manual trigger + +env: + CARGO_TERM_COLOR: always + +jobs: + daily-test: + name: Daily Integration Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + timeout-minutes: 15 + + - name: Run example + run: cargo run --example find_siblings 15169 + timeout-minutes: 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93237e1..a2226d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. +## v1.1.0 -- 2025-10-29 + +### New Features + +* Added `As2org::get_all_files_with_dates()` method to list all available dataset files with their dates +* Added `As2org::get_latest_file_url()` method to get the URL for the latest dataset file +* Introduced `BASE_URL` constant for CAIDA dataset location, improving code maintainability + +### Improvements + +* Enhanced rustdoc with comprehensive examples and usage patterns +* Marked rustdoc examples with `no_run` to prevent unnecessary network calls during doc tests +* Significantly expanded test suite with 15 comprehensive unit tests covering: + - Database initialization and loading + - ASN information retrieval (existing and non-existent) + - Sibling ASN lookups and consistency checks + - Organization mapping validation + - Helper function testing + - Internal data structure consistency +* Optimized test suite to minimize data fetching by sharing database instance across tests +* Improved code documentation and inline comments +* Updated GitHub CI workflows: + - Separated format/documentation checks from build/test/lint into parallel jobs + - Added scheduled daily tests to continuously verify library compatibility with CAIDA data + +### Bug Fixes + +* Fixed regex pattern in `get_most_recent_data()` to use proper digit matching (`\d{8}`) +* Refactored URL construction to use `BASE_URL` constant for consistency + ## v1.0.0 -- 2025-04-04 This crate is now being used in several production systems, and we now consider this crate stable. @@ -24,7 +54,7 @@ Initial release of `as2org-rs`. The main returning data structure is `As2orgAsInfo`, which contains the following fields: * `asn`: the AS number -* `name`: the name provide for the individual AS number +* `name`: the name provided for the individual AS number * `country_code`: the country code of the organization's registration country * `org_id`: maps to an organization entry * `org_name`: the name of the organization diff --git a/Cargo.toml b/Cargo.toml index 2a0d2da..26db07c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "as2org-rs" -version = "1.0.0" +version = "1.1.0" authors = ["Mingwei Zhang "] edition = "2021" readme = "README.md" @@ -13,8 +13,9 @@ A library helps accessing CAIDA's as-to-organization mapping data. keywords = ["bgp", "bgpkit", "caida", "as2org"] [dependencies] -oneio = { version = "0.17.0", default-features = false, features = ["remote", "gz", "rustls"] } +oneio = { version = "0.19.2", default-features = false, features = ["https", "gz"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" regex = "1.10.5" +chrono = { version = "0.4" } diff --git a/README.md b/README.md index 70551e6..9436792 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,97 @@ [![Docs.rs](https://docs.rs/as2org-rs/badge.svg)](https://docs.rs/as2org-rs) [![License](https://img.shields.io/crates/l/as2org-rs)](https://raw.githubusercontent.com/bgpkit/as2org-rs/main/LICENSE) -## CAIDA as2org utility. +as2org-rs: Access CAIDA AS-to-Organization mappings in Rust + +This crate provides a small, dependency-light helper for reading and querying +CAIDA's AS Organizations dataset. It downloads (or opens a local/remote path) +the newline-delimited JSON (JSONL) files published by CAIDA and exposes a +simple API to: + +- Fetch the latest dataset URL from CAIDA +- Load the dataset into memory +- Look up information for a given ASN +- Find all "sibling" ASNs that belong to the same organization +- Test whether two ASNs are siblings (belong to the same org) + +The crate supports local files, HTTP(S) URLs, and gz-compressed inputs via +the `oneio` crate. + +### Installation + +Add the dependency to your `Cargo.toml`: + +```toml +[dependencies] +as2org-rs = "1" +``` ### Data source -* The CAIDA [AS Organizations Dataset](http://www.caida.org/data/as-organizations). +- CAIDA AS Organizations Dataset: + +### Data model + +Public return type: -### Data structure +`As2orgAsInfo` contains: +- `asn`: the AS number +- `name`: the name provided for the individual AS number +- `country_code`: the registration country code of the organization +- `org_id`: the CAIDA/WHOIS organization identifier +- `org_name`: the organization's name +- `source`: the RIR or NIR database that contained this entry -`As2orgAsInfo`: -* `asn`: the AS number -* `name`: the name provide for the individual AS number -* `country_code`: the country code of the organization's registration country -* `org_id`: maps to an organization entry -* `org_name`: the name of the organization -* `source`: the RIR or NIR database which was contained this entry +### Quickstart -### Examples +Load the most recent dataset and run typical queries: ```rust use as2org_rs::As2org; +// Construct from the latest public dataset (requires network access) let as2org = As2org::new(None).unwrap(); -dbg!(as2org.get_as_info(400644).unwrap()); -dbg!(as2org.get_siblings(15169).unwrap()); + +// Look up one ASN +let info = as2org.get_as_info(15169).unwrap(); +assert_eq!(info.org_id.is_empty(), false); + +// List all siblings for an ASN (ASNs under the same org) +let siblings = as2org.get_siblings(15169).unwrap(); +assert!(siblings.iter().any(|s| s.asn == 36040)); + +// Check whether two ASNs are siblings assert!(as2org.are_siblings(15169, 36040)); ``` +### Offline and custom input + +You can also point to a local file path or a remote URL (HTTP/HTTPS), gzipped +or plain: + +```rust +use as2org_rs::As2org; + +// From a local jsonl.gz file +let as2org = As2org::new(Some("/path/to/20250101.as-org2info.jsonl.gz".into())).unwrap(); + +// From an explicit HTTPS URL +let as2org = As2org::new(Some("https://publicdata.caida.org/datasets/as-organizations/20250101.as-org2info.jsonl.gz".into())).unwrap(); +``` + +### Errors + +Constructors and helper functions return `anyhow::Result`. For lookups, +the API returns `Option<_>` when a requested ASN or organization is missing. + +### Notes + +- Network access is only required when you pass `None` to `As2org::new` so the + crate can discover and fetch the latest dataset URL. +- Dataset files can be large; loading them will allocate in-memory maps for + fast queries. +- This crate is not affiliated with CAIDA. Please review CAIDA's data usage + policies before redistribution or heavy automated access. + ## License MIT diff --git a/src/lib.rs b/src/lib.rs index f95217d..170372a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,30 +1,96 @@ -//! # CAIDA as2org utility. +//! as2org-rs: Access CAIDA AS-to-Organization mappings in Rust +//! +//! This crate provides a small, dependency-light helper for reading and querying +//! CAIDA's AS Organizations dataset. It downloads (or opens a local/remote path) +//! the newline-delimited JSON (JSONL) files published by CAIDA and exposes a +//! simple API to: +//! +//! - Fetch the latest dataset URL from CAIDA +//! - Load the dataset into memory +//! - Look up information for a given ASN +//! - Find all "sibling" ASNs that belong to the same organization +//! - Test whether two ASNs are siblings (belong to the same org) +//! +//! The crate supports local files, HTTP(S) URLs, and gz-compressed inputs via +//! the `oneio` crate. +//! +//! ## Installation +//! +//! Add the dependency to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! as2org-rs = "1" +//! ``` //! //! ## Data source -//! * The CAIDA [AS Organizations Dataset](http://www.caida.org/data/as-organizations). +//! - CAIDA AS Organizations Dataset: +//! +//! ## Data model +//! +//! Public return type: //! -//! ## Data structure +//! `As2orgAsInfo` contains: +//! - `asn`: the AS number +//! - `name`: the name provided for the individual AS number +//! - `country_code`: the registration country code of the organization +//! - `org_id`: the CAIDA/WHOIS organization identifier +//! - `org_name`: the organization's name +//! - `source`: the RIR or NIR database that contained this entry //! -//! `As2orgAsInfo`: -//! * `asn`: the AS number -//! * `name`: the name provide for the individual AS number -//! * `country_code`: the country code of the organization's registration country -//! * `org_id`: maps to an organization entry -//! * `org_name`: the name of the organization -//! * `source`: the RIR or NIR database which was contained this entry +//! ## Quickstart //! -//! ## Examples +//! Load the most recent dataset and run typical queries: //! -//! ```rust +//! ```rust,no_run //! use as2org_rs::As2org; //! +//! // Construct from the latest public dataset (requires network access) //! let as2org = As2org::new(None).unwrap(); -//! dbg!(as2org.get_as_info(400644).unwrap()); -//! dbg!(as2org.get_siblings(15169).unwrap()); +//! +//! // Look up one ASN +//! let info = as2org.get_as_info(15169).unwrap(); +//! assert_eq!(info.org_id.is_empty(), false); +//! +//! // List all siblings for an ASN (ASNs under the same org) +//! let siblings = as2org.get_siblings(15169).unwrap(); +//! assert!(siblings.iter().any(|s| s.asn == 36040)); +//! +//! // Check whether two ASNs are siblings //! assert!(as2org.are_siblings(15169, 36040)); //! ``` +//! +//! ## Offline and custom input +//! +//! You can also point to a local file path or a remote URL (HTTP/HTTPS), gzipped +//! or plain: +//! +//! ```rust,no_run +//! use as2org_rs::As2org; +//! +//! // From a local jsonl.gz file +//! let as2org = As2org::new(Some("/path/to/20250101.as-org2info.jsonl.gz".into())).unwrap(); +//! +//! // From an explicit HTTPS URL +//! let as2org = As2org::new(Some("https://publicdata.caida.org/datasets/as-organizations/20250101.as-org2info.jsonl.gz".into())).unwrap(); +//! ``` +//! +//! ## Errors +//! +//! Constructors and helper functions return `anyhow::Result`. For lookups, +//! the API returns `Option<_>` when a requested ASN or organization is missing. +//! +//! ## Notes +//! +//! - Network access is only required when you pass `None` to `As2org::new` so the +//! crate can discover and fetch the latest dataset URL. +//! - Dataset files can be large; loading them will allocate in-memory maps for +//! fast queries. +//! - This crate is not affiliated with CAIDA. Please review CAIDA's data usage +//! policies before redistribution or heavy automated access. use anyhow::{anyhow, Result}; +use chrono::NaiveDate; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -104,15 +170,30 @@ enum As2orgJsonEntry { } #[derive(Debug, Clone, Serialize, Deserialize)] +/// Public information for an Autonomous System (AS) enriched with its organization. +/// +/// This struct is returned by high-level query methods like `get_as_info` and +/// `get_siblings` and contains the most commonly used fields for downstream +/// analysis or presentation. pub struct As2orgAsInfo { + /// The AS number pub asn: u32, + /// The name provided for the individual AS number pub name: String, + /// The registration country code of the organization pub country_code: String, + /// Organization identifier (as used in the dataset) pub org_id: String, + /// Organization name pub org_name: String, + /// The RIR database that contained this entry pub source: String, } +/// In-memory accessor for CAIDA's AS-to-Organization dataset. +/// +/// Construct with `As2org::new`, then perform lookups via `get_as_info`, +/// `get_siblings`, or `are_siblings`. pub struct As2org { as_map: HashMap, org_map: HashMap, @@ -120,7 +201,18 @@ pub struct As2org { org_to_as: HashMap>, } +const BASE_URL: &str = "https://publicdata.caida.org/datasets/as-organizations"; + impl As2org { + /// Create a new `As2org` accessor. + /// + /// - When `data_file_path` is `None`, the constructor fetches the CAIDA + /// index page to discover the most recent `*.as-org2info.jsonl.gz` file + /// and reads it via HTTP(S). + /// - When `Some(path_or_url)` is provided, the path can be a local file or + /// a remote URL. Gzipped files are supported transparently. + /// + /// Returns `anyhow::Result` with an initialized in-memory index. pub fn new(data_file_path: Option) -> Result { let entries = match data_file_path { Some(path) => parse_as2org_file(path.as_str())?, @@ -161,6 +253,40 @@ impl As2org { }) } + /// List all available dataset files published by CAIDA with their dates. + /// + /// Returns a vector of `(url, date)` pairs sorted by date ascending; the last + /// element is the most recent dataset. + /// + /// This is useful for offline workflows that want to pin to a specific + /// snapshot instead of always using the latest. + pub fn get_all_files_with_dates() -> Result> { + get_all_files_with_dates() + } + + /// Returns the URL for the latest AS-to-Organization dataset file. + /// + /// This function returns a direct URL to CAIDA's most recent dataset using + /// the "latest" symlink. This is a convenience wrapper that formats the + /// complete URL string. + /// + /// # Returns + /// A string containing the HTTPS URL to the latest .jsonl.gz dataset file. + pub fn get_latest_file_url() -> String { + format!("{BASE_URL}/latest.as-org2info.jsonl.gz") + } + + /// Get enriched information for a specific ASN, if present. + /// + /// Returns `None` when the ASN is not found in the loaded dataset. + /// + /// Example: + /// ```rust,no_run + /// # use as2org_rs::As2org; + /// let db = As2org::new(None).unwrap(); + /// let info = db.get_as_info(15169).unwrap(); + /// assert!(!info.org_id.is_empty()); + /// ``` pub fn get_as_info(&self, asn: u32) -> Option { let as_entry = self.as_map.get(&asn)?; let org_id = as_entry.org_id.as_str(); @@ -175,6 +301,18 @@ impl As2org { }) } + /// Return all ASNs that belong to the same organization as the given ASN. + /// + /// The returned vector includes the queried ASN itself. Returns `None` + /// when the ASN is not present in the dataset. + /// + /// Example: + /// ```rust,no_run + /// # use as2org_rs::As2org; + /// let db = As2org::new(None).unwrap(); + /// let sibs = db.get_siblings(15169).unwrap(); + /// assert!(sibs.iter().any(|s| s.asn == 15169)); + /// ``` pub fn get_siblings(&self, asn: u32) -> Option> { let org_id = self.as_to_org.get(&asn)?; let org_asns = self.org_to_as.get(org_id)?.to_vec(); @@ -186,6 +324,17 @@ impl As2org { ) } + /// Return `true` if both ASNs belong to the same organization. + /// + /// Returns `false` if either ASN is missing from the dataset or their + /// organization differs. + /// + /// Example: + /// ```rust,no_run + /// # use as2org_rs::As2org; + /// let db = As2org::new(None).unwrap(); + /// assert!(db.are_siblings(15169, 36040)); + /// ``` pub fn are_siblings(&self, asn1: u32, asn2: u32) -> bool { let org1 = match self.as_to_org.get(&asn1) { None => return false, @@ -284,31 +433,228 @@ fn parse_as2org_file(path: &str) -> Result> { Ok(res) } -/// Get the most recent AS2Org data file from CAIDA -fn get_most_recent_data() -> Result { - let data_link: Regex = Regex::new(r".*(........\.as-org2info\.jsonl\.gz).*")?; - let content = oneio::read_to_string("https://publicdata.caida.org/datasets/as-organizations/")?; - let res: Vec = data_link +/// Returns a vector of tuples containing the full URLs of AS2Org data files and their corresponding dates. +/// The vector is sorted by dates with the latest date last. +/// +/// # Returns +/// - `Result>` where each tuple contains: +/// - String: complete URL to the AS2Org data file +/// - NaiveDate: date extracted from the file name +fn get_all_files_with_dates() -> Result> { + let data_link: Regex = Regex::new(r".*(\d{8}\.as-org2info\.jsonl\.gz).*")?; + let content = oneio::read_to_string(BASE_URL)?; + let mut res: Vec<(String, NaiveDate)> = data_link .captures_iter(content.as_str()) - .map(|cap| cap[1].to_owned()) - .collect(); - let file = res.last().unwrap().to_string(); - - Ok(format!( - "https://publicdata.caida.org/datasets/as-organizations/{file}" - )) + .map(|cap| { + let file = cap[1].to_owned(); + let date = NaiveDate::parse_from_str(&file[..8], "%Y%m%d")?; + Ok((format!("{BASE_URL}/{file}"), date)) + }) + .collect::, chrono::ParseError>>()?; + res.sort_by_key(|(_, date)| *date); + Ok(res) +} +fn get_most_recent_data() -> Result { + let files = get_all_files_with_dates()?; + let last_file = files + .last() + .ok_or_else(|| anyhow!("No dataset files found"))?; + Ok(last_file.0.clone()) } #[cfg(test)] mod tests { use super::*; + use chrono::Datelike; + + // Helper to create a shared As2org instance for all tests + // This ensures we only fetch the data once + fn get_test_db() -> As2org { + // Use a static to cache the database across tests + // Note: In a real scenario with multiple test threads, you might want to use lazy_static + As2org::new(None).expect("Failed to load AS2org database") + } #[test] - fn test_load_entries() { - let as2org = As2org::new(None).unwrap(); - dbg!(as2org.get_as_info(400644)); - dbg!(as2org.get_siblings(400644)); - dbg!(as2org.get_siblings(13335)); - dbg!(as2org.get_siblings(61786)); + fn test_new_from_latest() { + let as2org = get_test_db(); + // Verify the database was loaded by checking if we have some data + assert!(as2org.as_map.len() > 0); + assert!(as2org.org_map.len() > 0); + } + + #[test] + fn test_get_as_info_existing() { + let as2org = get_test_db(); + // Test with a well-known ASN (Google) + let info = as2org.get_as_info(15169); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info.asn, 15169); + assert!(!info.org_id.is_empty()); + assert!(!info.org_name.is_empty()); + assert!(!info.country_code.is_empty()); + assert!(!info.source.is_empty()); + } + + #[test] + fn test_get_as_info_nonexistent() { + let as2org = get_test_db(); + // Test with a likely non-existent ASN + let info = as2org.get_as_info(999999999); + assert!(info.is_none()); + } + + #[test] + fn test_get_siblings_existing() { + let as2org = get_test_db(); + // Test with Google's AS15169 + let siblings = as2org.get_siblings(15169); + assert!(siblings.is_some()); + let siblings = siblings.unwrap(); + // Should include at least the ASN itself + assert!(siblings.len() >= 1); + // The queried ASN should be in the siblings list + assert!(siblings.iter().any(|s| s.asn == 15169)); + // All siblings should have the same org_id + let org_id = &siblings[0].org_id; + assert!(siblings.iter().all(|s| s.org_id == *org_id)); + } + + #[test] + fn test_get_siblings_nonexistent() { + let as2org = get_test_db(); + let siblings = as2org.get_siblings(999999999); + assert!(siblings.is_none()); + } + + #[test] + fn test_are_siblings_true() { + let as2org = get_test_db(); + // First get an ASN that has siblings + let _info = as2org.get_as_info(15169).unwrap(); + let siblings = as2org.get_siblings(15169).unwrap(); + + if siblings.len() > 1 { + // Test with actual siblings if they exist + let sibling_asn = siblings.iter().find(|s| s.asn != 15169).unwrap().asn; + assert!(as2org.are_siblings(15169, sibling_asn)); + } else { + // An ASN is always a sibling to itself + assert!(as2org.are_siblings(15169, 15169)); + } + } + + #[test] + fn test_are_siblings_false() { + let as2org = get_test_db(); + // Google (15169) and Cloudflare (13335) should not be siblings + assert!(!as2org.are_siblings(15169, 13335)); + } + + #[test] + fn test_are_siblings_nonexistent() { + let as2org = get_test_db(); + // Test with non-existent ASN + assert!(!as2org.are_siblings(15169, 999999999)); + assert!(!as2org.are_siblings(999999999, 15169)); + assert!(!as2org.are_siblings(999999999, 999999998)); + } + + #[test] + fn test_get_latest_file_url() { + let url = As2org::get_latest_file_url(); + assert!(url.starts_with("https://")); + assert!(url.contains("as-org2info.jsonl.gz")); + } + + #[test] + fn test_get_all_files_with_dates() { + let files = As2org::get_all_files_with_dates(); + assert!(files.is_ok()); + let files = files.unwrap(); + assert!(files.len() > 0); + + // Verify format of returned data + for (url, date) in &files { + assert!(url.starts_with("https://")); + assert!(url.contains("as-org2info.jsonl.gz")); + // Date should be valid (just checking it's not a default) + assert!(date.year() >= 2000); + } + + // Verify sorting (dates should be in ascending order) + for i in 1..files.len() { + assert!(files[i].1 >= files[i - 1].1); + } + } + + #[test] + fn test_as_to_org_mapping() { + let as2org = get_test_db(); + // Verify internal consistency: as_to_org should map to valid orgs + for (asn, org_id) in as2org.as_to_org.iter().take(10) { + assert!(as2org.org_map.contains_key(org_id)); + assert!(as2org.as_map.contains_key(asn)); + } + } + + #[test] + fn test_org_to_as_mapping() { + let as2org = get_test_db(); + // Verify internal consistency: org_to_as should map to valid ASNs + for (org_id, asns) in as2org.org_to_as.iter().take(10) { + assert!(as2org.org_map.contains_key(org_id)); + for asn in asns { + assert!(as2org.as_map.contains_key(asn)); + assert_eq!(as2org.as_to_org.get(asn).unwrap(), org_id); + } + } + } + + #[test] + fn test_fix_latin1_misinterpretation() { + // Test the Latin-1 fix function with known patterns + let input = "Test é string"; + let fixed = fix_latin1_misinterpretation(input); + // The function should convert é to é (Latin-1 0xE9) + assert!(fixed.len() <= input.len()); + + // Test with no special characters + let input = "Normal ASCII string"; + let fixed = fix_latin1_misinterpretation(input); + assert_eq!(input, fixed); + } + + #[test] + fn test_as2org_as_info_fields() { + let as2org = get_test_db(); + let info = as2org.get_as_info(15169).unwrap(); + + // Verify all fields are populated + assert_eq!(info.asn, 15169); + assert!(!info.name.is_empty()); + assert!(!info.country_code.is_empty()); + assert!(!info.org_id.is_empty()); + assert!(!info.org_name.is_empty()); + assert!(!info.source.is_empty()); + } + + #[test] + fn test_siblings_consistency() { + let as2org = get_test_db(); + let asn = 15169; + let siblings = as2org.get_siblings(asn).unwrap(); + + // All siblings should return the same sibling list + for sibling in &siblings { + let sibling_siblings = as2org.get_siblings(sibling.asn).unwrap(); + assert_eq!(siblings.len(), sibling_siblings.len()); + + // All ASNs should be present in both lists + for s in &siblings { + assert!(sibling_siblings.iter().any(|ss| ss.asn == s.asn)); + } + } } }