This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 09:42:10 +02:00
1 parent c24bc4279b
commit 3b3f0db377
84 files changed
+2686 -392

No files matched your search

+93 -30
View File
@@ -1,8 +1,16 @@
//! Minimal reader for sapics/ip-location-db user-country CSV files.
//! Installer verification pins each downloaded file to the publisher-provided
//! SHA-256 checksum; runtime parsing stays dependency-free and bounded.
//! Bounded country GeoIP reader for direct CSV pairs and native MaxMind DB files.
//! Managed downloads use direct HTTPS sources; publisher checksum URLs are optional.
mod mmdb;
mod update;
mod update_http;
pub use update::{
UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL,
DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL,
};
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
use anyhow::{bail, ensure, Context, Result};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::{BufRead, BufReader},
@@ -25,52 +33,101 @@ struct V6Range {
end: u128,
country: [u8; 2],
}
#[derive(Debug, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DatabaseFormat {
CsvPair,
Mmdb,
}
impl Default for DatabaseFormat {
fn default() -> Self {
Self::CsvPair
}
}
#[derive(Debug)]
enum Backend {
Csv { ipv4: Vec<V4Range>, ipv6: Vec<V6Range> },
Mmdb(mmdb::Database),
}
#[derive(Debug)]
pub struct Database {
ipv4: Vec<V4Range>,
ipv6: Vec<V6Range>,
backend: Backend,
}
#[derive(Debug, Clone, Serialize)]
pub struct View {
pub loaded: bool,
pub ipv4_ranges: usize,
pub ipv6_ranges: usize,
pub source: &'static str,
pub source: String,
pub format: &'static str,
}
impl Database {
pub fn empty() -> Self {
Self::default()
Self { backend: Backend::Csv { ipv4: Vec::new(), ipv6: Vec::new() } }
}
pub fn load(directory: &Path) -> Result<Self> {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(
!ipv4.is_empty() && !ipv6.is_empty(),
"GeoIP country database is empty"
);
Ok(Self { ipv4, ipv6 })
if directory.join("country.mmdb").is_file() {
Self::load_format(directory, DatabaseFormat::Mmdb)
} else {
Self::load_format(directory, DatabaseFormat::CsvPair)
}
}
pub fn load_format(directory: &Path, format: DatabaseFormat) -> Result<Self> {
match format {
DatabaseFormat::CsvPair => {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty");
Ok(Self { backend: Backend::Csv { ipv4, ipv6 } })
}
DatabaseFormat::Mmdb => Ok(Self {
backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?),
}),
}
}
pub fn loaded(&self) -> bool {
!self.ipv4.is_empty() || !self.ipv6.is_empty()
match &self.backend {
Backend::Csv { ipv4, ipv6 } => !ipv4.is_empty() || !ipv6.is_empty(),
Backend::Mmdb(_) => true,
}
}
pub fn view(&self) -> View {
View {
loaded: self.loaded(),
ipv4_ranges: self.ipv4.len(),
ipv6_ranges: self.ipv6.len(),
source: "sapics/ip-location-db user-country (PDDL)",
match &self.backend {
Backend::Csv { ipv4, ipv6 } => View {
loaded: self.loaded(),
ipv4_ranges: ipv4.len(),
ipv6_ranges: ipv6.len(),
source: "CSV country ranges".into(),
format: "csv_pair",
},
Backend::Mmdb(database) => View {
loaded: true,
ipv4_ranges: 0,
ipv6_ranges: 0,
source: format!("MaxMind DB ({})", database.database_type()),
format: "mmdb",
},
}
}
pub fn country(&self, ip: IpAddr) -> Option<String> {
let code = match crate::vhost::canonical_ip(ip) {
IpAddr::V4(ip) => find_v4(&self.ipv4, u32::from(ip)),
IpAddr::V6(ip) => find_v6(&self.ipv6, u128::from(ip)),
}?;
Some(String::from_utf8_lossy(&code).into_owned())
let ip = crate::vhost::canonical_ip(ip);
match &self.backend {
Backend::Csv { ipv4, ipv6 } => {
let code = match ip {
IpAddr::V4(ip) => find_v4(ipv4, u32::from(ip)),
IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip)),
}?;
Some(String::from_utf8_lossy(&code).into_owned())
}
Backend::Mmdb(database) => database.country(ip),
}
}
}
fn country_code(raw: &str) -> Option<[u8; 2]> {
pub(super) fn normalize_country_code(raw: &str) -> Option<[u8; 2]> {
let raw = raw.trim().trim_matches('"').as_bytes();
if raw.len() == 2 && raw.iter().all(|b| b.is_ascii_alphabetic()) {
Some([raw[0].to_ascii_uppercase(), raw[1].to_ascii_uppercase()])
@@ -129,7 +186,7 @@ fn parse_v4(path: &Path) -> Result<Vec<V4Range>> {
);
}
previous_end = Some(end);
if let Some(country) = country_code(country) {
if let Some(country) = normalize_country_code(country) {
result.push(V4Range {
start,
end,
@@ -168,7 +225,7 @@ fn parse_v6(path: &Path) -> Result<Vec<V6Range>> {
);
}
previous_end = Some(end);
if let Some(country) = country_code(country) {
if let Some(country) = normalize_country_code(country) {
result.push(V6Range {
start,
end,
@@ -200,5 +257,11 @@ pub fn validate_directory(directory: &Path) -> Result<()> {
.or_else(|error| bail!("GeoIP country database is invalid: {error:#}"))
}
pub fn validate_directory_format(directory: &Path, format: DatabaseFormat) -> Result<()> {
Database::load_format(directory, format)
.map(|_| ())
.or_else(|error| bail!("GeoIP country database is invalid: {error:#}"))
}
#[cfg(test)]
include!("geoip/tests.rs");