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}