ip_alloc_lookup/
lib.rs

1//! Offline IP-to-country and region classification based on RIPE NCC data.
2//!
3//! This crate provides a lightweight, allocation-based alternative to
4//! MaxMind-style GeoIP databases. Instead of city-level precision, it focuses on:
5//!
6//! - Country code (ISO-3166 alpha-2)
7//! - Coarse regional grouping (EU / non-EU, etc.)
8//! - Fully offline, deterministic lookups
9//!
10//! ## Data source
11//!
12//! The database is built from RIPE NCC “delegated statistics” files, which list
13//! IPv4 and IPv6 address allocations by country. These files are:
14//!
15//! - Public
16//! - Regularly updated
17//! - Easy to parse and cache
18//!
19//! By default, a preprocessed snapshot is embedded at compile time for
20//! zero-I/O runtime lookups.
21//!
22//! ## Design goals
23//!
24//! - No runtime network access required
25//! - Minimal memory usage
26//! - Fast lookups via binary search
27//! - Simple API suitable for policy decisions (e.g. GDPR / EU checks)
28//!
29//! ## Limitations
30//!
31//! This crate does **not** attempt to provide:
32//!
33//! - City or ISP precision
34//! - User location inference
35//! - Dynamic routing awareness
36//!
37//! It reflects allocation data, not actual physical location.
38
39mod database;
40
41// Re-export public API
42pub use database::{GeoIpDb, GeoInfo, DbStats};
43
44// We keep the parser public for users who want to work with raw RIPE data
45use std::net::{Ipv4Addr, Ipv6Addr};
46
47/// A single allocation block parsed from a RIPE delegated statistics file.
48///
49/// For IPv4 blocks, `start_v4` is `Some` and `start_v6` is `None`.
50/// For IPv6 blocks, `start_v6` is `Some` and `start_v4` is `None`.
51///
52/// `count` is the number of addresses in the block. For IPv6 lines, RIPE uses a
53/// prefix length in the “count” field; this parser converts that prefix length
54/// into an address count (`2^(128-prefix_len)`).
55#[derive(Debug, Clone, PartialEq)]
56pub struct IpRange {
57    pub start_v4: Option<Ipv4Addr>,
58    pub start_v6: Option<Ipv6Addr>,
59    pub count: u128,
60    pub country: String,
61}
62
63/// Parse RIPE NCC “delegated-*” statistics content into allocation ranges.
64///
65/// This parser is intentionally simple:
66/// - Ignores comment lines (`#...`) and summary/header lines starting with `2`.
67/// - Accepts only `ipv4` and `ipv6` records.
68/// - Keeps the two-letter country code exactly as present in the file.
69///
70/// For IPv4 records, `count` is the number of addresses.
71/// For IPv6 records, RIPE encodes the *prefix length* in the “count” field; this
72/// function converts it to an address count.
73///
74/// # Examples
75/// ```
76/// use offline_ripe_geoip::parse_ripe_delegated;
77///
78/// let data = "ripencc|DE|ipv4|46.4.0.0|256|20250101|allocated\n";
79/// let ranges = parse_ripe_delegated(data);
80/// assert_eq!(ranges.len(), 1);
81/// assert_eq!(ranges[0].country, "DE");
82/// ```
83///
84/// # Notes
85/// This does not validate that the returned ranges are non-overlapping or sorted.
86pub fn parse_ripe_delegated(content: &str) -> Vec<IpRange> {
87    content
88        .lines()
89        .filter(|line| {
90            !line.starts_with('#')
91                && !line.starts_with('2')
92                && (line.contains("ipv4") || line.contains("ipv6"))
93        })
94        .filter_map(|line| {
95            let parts: Vec<&str> = line.split('|').collect();
96
97            if parts.len() < 7 {
98                return None;
99            }
100
101            let ip_type = parts[2];
102            let country = parts[1].to_string();
103
104            if ip_type == "ipv4" {
105                Some(IpRange {
106                    start_v4: parts[3].parse().ok(),
107                    start_v6: None,
108                    count: parts[4].parse::<u32>().ok()? as u128,
109                    country,
110                })
111            } else if ip_type == "ipv6" {
112                // For IPv6, the count field is actually the prefix length
113                let prefix_len: u32 = parts[4].parse().ok()?;
114                let host_bits = 128 - prefix_len;
115                let count = if host_bits >= 128 {
116                    u128::MAX
117                } else {
118                    1u128 << host_bits
119                };
120
121                Some(IpRange {
122                    start_v4: None,
123                    start_v6: parts[3].parse().ok(),
124                    count,
125                    country,
126                })
127            } else {
128                None
129            }
130        })
131        .collect()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::net::IpAddr;
138
139    #[test]
140    fn test_basic_usage() {
141        // This is what users will actually do
142        let db = GeoIpDb::new();
143
144        println!("\n Testing basic library usage:");
145
146        // Test a German IPv4
147        let ipv4: IpAddr = "46.4.0.1".parse().unwrap();
148        if let Some(info) = db.lookup(ipv4) {
149            println!("  46.4.0.1 -> {} (EU: {})", info.country_code_str(), info.is_eu);
150            assert!(info.is_eu);
151        }
152
153        // Test convenience method
154        let is_eu = db.is_eu("46.4.0.1".parse().unwrap());
155        println!("  is_eu(46.4.0.1) = {}", is_eu);
156
157        // Show stats
158        let stats = db.stats();
159        println!("  Database: {} IPv4 ranges ({} EU, {} non-EU)",
160            stats.total_v4_ranges, stats.eu_v4_ranges, stats.non_eu_v4_ranges);
161        println!("            {} IPv6 ranges ({} EU, {} non-EU)",
162            stats.total_v6_ranges, stats.eu_v6_ranges, stats.non_eu_v6_ranges);
163    }
164
165    #[test]
166    fn test_ipv6_lookup() {
167        let db = GeoIpDb::new();
168
169        // Try to look up a common European IPv6 address
170        let ipv6: IpAddr = "2a01:4f8::1".parse().unwrap();
171        
172        if let Some(info) = db.lookup(ipv6) {
173            println!("  2a01:4f8::1 -> {} (EU: {})", info.country_code_str(), info.is_eu);
174        } else {
175            println!("  2a01:4f8::1 not found in database");
176        }
177    }
178}