lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 68cfeaa9d58ce123802ba260bd163cd79c910f19
parent 3538714739bf448682ecfabf5495818994bb8f9c
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 05:58:10 +0000

geocoder: add verified geonames runtime asset

Diffstat:
MCargo.lock | 6++++++
Mcrates/geocoder/Cargo.toml | 5+++++
Acrates/geocoder/src/asset.rs | 606+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/geocoder/src/error.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Mcrates/geocoder/src/geocoder.rs | 15++++++++++++---
Mcrates/geocoder/src/lib.rs | 10++++++++++
Mcrates/runtime_paths/src/conventions.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/runtime_paths/src/lib.rs | 12++++++++----
8 files changed, 741 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4333,10 +4333,15 @@ dependencies = [ name = "radroots_geocoder" version = "0.1.0-alpha.2" dependencies = [ + "hex", + "radroots_runtime_paths", + "reqwest", "rusqlite", "serde", + "sha2", "tempfile", "thiserror 1.0.69", + "url", ] [[package]] @@ -5064,6 +5069,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml @@ -12,10 +12,15 @@ homepage.workspace = true readme = "README" [dependencies] +hex = { workspace = true } +radroots_runtime_paths = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } rusqlite = { workspace = true, features = ["bundled"] } serde = { workspace = true, features = ["derive"] } +sha2 = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } +url = { workspace = true } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/geocoder/src/asset.rs b/crates/geocoder/src/asset.rs @@ -0,0 +1,606 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use radroots_runtime_paths::default_shared_geonames_database_path_from_cache_root; +use rusqlite::{Connection, OpenFlags}; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::GeocoderError; + +pub const GEONAMES_ASSET_VERSION: &str = "1.0"; +pub const GEONAMES_ASSET_FILE_NAME: &str = "geonames-1.0.db"; +pub const GEONAMES_ASSET_URL: &str = "https://assets.radroots.io/data/geonames/geonames-1.0.db"; +pub const GEONAMES_ASSET_HOST: &str = "assets.radroots.io"; +pub const GEONAMES_ASSET_BYTE_SIZE: u64 = 12_951_552; +pub const GEONAMES_ASSET_SHA256: &str = + "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c"; + +pub const GEONAMES_1_0_ASSET: GeoNamesAssetSpec = GeoNamesAssetSpec { + version: GEONAMES_ASSET_VERSION, + file_name: GEONAMES_ASSET_FILE_NAME, + url: GEONAMES_ASSET_URL, + allowed_host: GEONAMES_ASSET_HOST, + byte_size: GEONAMES_ASSET_BYTE_SIZE, + sha256: GEONAMES_ASSET_SHA256, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GeoNamesAssetSpec { + pub version: &'static str, + pub file_name: &'static str, + pub url: &'static str, + pub allowed_host: &'static str, + pub byte_size: u64, + pub sha256: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum GeoNamesAssetState { + Missing, + Available, + Invalid, + Refreshed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeoNamesAssetStatus { + pub state: GeoNamesAssetState, + pub version: String, + pub path: PathBuf, + pub byte_size: Option<u64>, + pub sha256: Option<String>, + pub validation_error: Option<String>, +} + +pub trait GeoNamesAssetFetcher { + fn fetch(&self, url: &str) -> Result<Vec<u8>, GeocoderError>; +} + +#[derive(Clone, Debug, Default)] +pub struct GeoNamesBlockingHttpFetcher; + +impl GeoNamesAssetFetcher for GeoNamesBlockingHttpFetcher { + fn fetch(&self, url: &str) -> Result<Vec<u8>, GeocoderError> { + let response = + reqwest::blocking::get(url).map_err(|source| GeocoderError::AssetDownload { + url: url.to_owned(), + source, + })?; + let response = + response + .error_for_status() + .map_err(|source| GeocoderError::AssetDownload { + url: url.to_owned(), + source, + })?; + response + .bytes() + .map(|bytes| bytes.to_vec()) + .map_err(|source| GeocoderError::AssetDownload { + url: url.to_owned(), + source, + }) + } +} + +pub fn default_geonames_asset_path_from_cache_root(cache_root: impl AsRef<Path>) -> PathBuf { + default_shared_geonames_database_path_from_cache_root(cache_root, GEONAMES_ASSET_VERSION) +} + +pub fn inspect_default_geonames_asset_in_cache_root( + cache_root: impl AsRef<Path>, +) -> Result<GeoNamesAssetStatus, GeocoderError> { + inspect_geonames_asset_path( + default_geonames_asset_path_from_cache_root(cache_root), + &GEONAMES_1_0_ASSET, + ) +} + +pub fn ensure_default_geonames_asset_in_cache_root( + cache_root: impl AsRef<Path>, +) -> Result<GeoNamesAssetStatus, GeocoderError> { + let fetcher = GeoNamesBlockingHttpFetcher; + ensure_geonames_asset_in_cache_root_with_fetcher(cache_root, &GEONAMES_1_0_ASSET, &fetcher) +} + +pub fn ensure_geonames_asset_in_cache_root_with_fetcher<F>( + cache_root: impl AsRef<Path>, + spec: &GeoNamesAssetSpec, + fetcher: &F, +) -> Result<GeoNamesAssetStatus, GeocoderError> +where + F: GeoNamesAssetFetcher, +{ + let path = default_shared_geonames_database_path_from_cache_root(cache_root, spec.version); + ensure_geonames_asset_path_with_fetcher(path, spec, fetcher) +} + +pub fn ensure_geonames_asset_path_with_fetcher<F>( + path: impl AsRef<Path>, + spec: &GeoNamesAssetSpec, + fetcher: &F, +) -> Result<GeoNamesAssetStatus, GeocoderError> +where + F: GeoNamesAssetFetcher, +{ + validate_geonames_asset_spec_source(spec)?; + let path = path.as_ref(); + let inspection = inspect_geonames_asset_path(path, spec)?; + if inspection.state == GeoNamesAssetState::Available { + return Ok(inspection); + } + let _lock = GeoNamesAssetLock::acquire(lock_path_for_asset(path))?; + let inspection = inspect_geonames_asset_path(path, spec)?; + if inspection.state == GeoNamesAssetState::Available { + return Ok(inspection); + } + let bytes = fetcher.fetch(spec.url)?; + install_geonames_asset_bytes(path, spec, &bytes)?; + let mut status = validate_geonames_asset_file(path, spec)?; + status.state = GeoNamesAssetState::Refreshed; + Ok(status) +} + +pub fn inspect_geonames_asset_path( + path: impl AsRef<Path>, + spec: &GeoNamesAssetSpec, +) -> Result<GeoNamesAssetStatus, GeocoderError> { + let path = path.as_ref(); + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(GeoNamesAssetStatus { + state: GeoNamesAssetState::Missing, + version: spec.version.to_owned(), + path: path.to_path_buf(), + byte_size: None, + sha256: None, + validation_error: None, + }); + } + Err(error) => return Err(GeocoderError::Io(error)), + }; + let actual_size = metadata.len(); + let actual_sha256 = sha256_file(path)?; + match validate_geonames_asset_file(path, spec) { + Ok(status) => Ok(status), + Err(error) if is_invalid_asset_error(&error) => Ok(GeoNamesAssetStatus { + state: GeoNamesAssetState::Invalid, + version: spec.version.to_owned(), + path: path.to_path_buf(), + byte_size: Some(actual_size), + sha256: Some(actual_sha256), + validation_error: Some(error.to_string()), + }), + Err(error) => Err(error), + } +} + +pub fn validate_geonames_asset_file( + path: impl AsRef<Path>, + spec: &GeoNamesAssetSpec, +) -> Result<GeoNamesAssetStatus, GeocoderError> { + let path = path.as_ref(); + let metadata = fs::metadata(path)?; + let actual_size = metadata.len(); + if actual_size != spec.byte_size { + return Err(GeocoderError::InvalidAssetLength { + path: path.to_path_buf(), + expected: spec.byte_size, + actual: actual_size, + }); + } + let actual_sha256 = sha256_file(path)?; + if actual_sha256 != spec.sha256 { + return Err(GeocoderError::InvalidAssetSha256 { + path: path.to_path_buf(), + expected: spec.sha256.to_owned(), + actual: actual_sha256, + }); + } + validate_sqlite_integrity_and_schema(path)?; + Ok(GeoNamesAssetStatus { + state: GeoNamesAssetState::Available, + version: spec.version.to_owned(), + path: path.to_path_buf(), + byte_size: Some(actual_size), + sha256: Some(actual_sha256), + validation_error: None, + }) +} + +pub fn validate_geonames_asset_spec_source(spec: &GeoNamesAssetSpec) -> Result<(), GeocoderError> { + let parsed = Url::parse(spec.url).map_err(|_| GeocoderError::InvalidAssetUrl { + url: spec.url.to_owned(), + })?; + if parsed.scheme() != "https" { + return Err(GeocoderError::InvalidAssetUrl { + url: spec.url.to_owned(), + }); + } + let actual_host = parsed.host_str().unwrap_or("").to_owned(); + if actual_host != spec.allowed_host { + return Err(GeocoderError::InvalidAssetHost { + url: spec.url.to_owned(), + expected_host: spec.allowed_host.to_owned(), + actual_host, + }); + } + Ok(()) +} + +fn install_geonames_asset_bytes( + path: &Path, + spec: &GeoNamesAssetSpec, + bytes: &[u8], +) -> Result<(), GeocoderError> { + if bytes.len() as u64 != spec.byte_size { + return Err(GeocoderError::InvalidAssetLength { + path: path.to_path_buf(), + expected: spec.byte_size, + actual: bytes.len() as u64, + }); + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let mut tempfile = tempfile::Builder::new() + .prefix(&format!(".{}.", spec.file_name)) + .suffix(".tmp") + .tempfile_in(parent)?; + tempfile.as_file_mut().write_all(bytes)?; + tempfile.as_file_mut().sync_all()?; + validate_geonames_asset_file(tempfile.path(), spec)?; + tempfile + .persist(path) + .map(|_| ()) + .map_err(|error| GeocoderError::Io(error.error)) +} + +fn validate_sqlite_integrity_and_schema(path: &Path) -> Result<(), GeocoderError> { + let conn = + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { + GeocoderError::InvalidAssetSqlite { + path: path.to_path_buf(), + detail: error.to_string(), + } + })?; + validate_sqlite_integrity(path, &conn)?; + for query in [ + "SELECT id, name FROM countries LIMIT 1", + "SELECT country_id, id, name FROM admin1 LIMIT 1", + "SELECT id, name, country_id, admin1_id FROM features LIMIT 1", + "SELECT feature_id, latitude, longitude FROM coordinates LIMIT 1", + "SELECT id, name, admin1_id, admin1_name, country_id, country_name, latitude, longitude FROM geonames LIMIT 1", + ] { + conn.prepare(query) + .map(|_| ()) + .map_err(|error| GeocoderError::InvalidAssetSchema { + path: path.to_path_buf(), + detail: error.to_string(), + })?; + } + Ok(()) +} + +fn validate_sqlite_integrity(path: &Path, conn: &Connection) -> Result<(), GeocoderError> { + let mut stmt = conn.prepare("PRAGMA integrity_check").map_err(|error| { + GeocoderError::InvalidAssetSqlite { + path: path.to_path_buf(), + detail: error.to_string(), + } + })?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| GeocoderError::InvalidAssetSqlite { + path: path.to_path_buf(), + detail: error.to_string(), + })?; + let results = + rows.collect::<Result<Vec<_>, _>>() + .map_err(|error| GeocoderError::InvalidAssetSqlite { + path: path.to_path_buf(), + detail: error.to_string(), + })?; + if results.as_slice() == ["ok"] { + return Ok(()); + } + Err(GeocoderError::InvalidAssetIntegrity { + path: path.to_path_buf(), + result: results.join("; "), + }) +} + +fn sha256_file(path: &Path) -> Result<String, GeocoderError> { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn lock_path_for_asset(path: &Path) -> PathBuf { + path.with_extension("db.lock") +} + +fn is_invalid_asset_error(error: &GeocoderError) -> bool { + matches!( + error, + GeocoderError::InvalidAssetLength { .. } + | GeocoderError::InvalidAssetSha256 { .. } + | GeocoderError::InvalidAssetSqlite { .. } + | GeocoderError::InvalidAssetIntegrity { .. } + | GeocoderError::InvalidAssetSchema { .. } + ) +} + +struct GeoNamesAssetLock { + path: PathBuf, +} + +impl GeoNamesAssetLock { + fn acquire(path: PathBuf) -> Result<Self, GeocoderError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(_) => Ok(Self { path }), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(GeocoderError::AssetLockUnavailable { path }) + } + Err(error) => Err(GeocoderError::Io(error)), + } + } +} + +impl Drop for GeoNamesAssetLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::fs; + use std::path::{Path, PathBuf}; + + use rusqlite::Connection; + use sha2::Digest; + + use super::{ + GEONAMES_ASSET_HOST, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, + ensure_geonames_asset_path_with_fetcher, inspect_geonames_asset_path, lock_path_for_asset, + validate_geonames_asset_file, validate_geonames_asset_spec_source, + }; + use crate::GeocoderError; + + const TEST_URL: &str = "https://assets.radroots.io/data/geonames/geonames-test.db"; + + struct BytesFetcher { + bytes: Vec<u8>, + calls: Cell<usize>, + } + + impl GeoNamesAssetFetcher for BytesFetcher { + fn fetch(&self, _url: &str) -> Result<Vec<u8>, GeocoderError> { + self.calls.set(self.calls.get() + 1); + Ok(self.bytes.clone()) + } + } + + #[test] + fn geonames_asset_missing_available_invalid_and_refreshed_states_are_reported() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let bytes = fixture_database_bytes(); + let spec = fixture_spec(&bytes, TEST_URL); + let target = tempdir.path().join("shared/geonames/geonames-test.db"); + + let missing = inspect_geonames_asset_path(&target, &spec).expect("missing inspection"); + assert_eq!(missing.state, GeoNamesAssetState::Missing); + assert_eq!(missing.byte_size, None); + + fs::create_dir_all(target.parent().expect("target parent")).expect("target parent"); + fs::write(&target, b"not sqlite").expect("write invalid"); + let invalid = inspect_geonames_asset_path(&target, &spec).expect("invalid inspection"); + assert_eq!(invalid.state, GeoNamesAssetState::Invalid); + assert!( + invalid + .validation_error + .expect("validation error") + .contains("length") + ); + + let fetcher = BytesFetcher { + bytes, + calls: Cell::new(0), + }; + let refreshed = + ensure_geonames_asset_path_with_fetcher(&target, &spec, &fetcher).expect("refresh"); + assert_eq!(refreshed.state, GeoNamesAssetState::Refreshed); + assert_eq!(fetcher.calls.get(), 1); + + let available = + ensure_geonames_asset_path_with_fetcher(&target, &spec, &fetcher).expect("available"); + assert_eq!(available.state, GeoNamesAssetState::Available); + assert_eq!(fetcher.calls.get(), 1); + } + + #[test] + fn geonames_asset_rejects_wrong_host_length_hash_sqlite_and_schema() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let bytes = fixture_database_bytes(); + + let bad_host_spec = fixture_spec( + &bytes, + "https://static.radroots.io/data/geonames/geonames-test.db", + ); + assert!(matches!( + validate_geonames_asset_spec_source(&bad_host_spec), + Err(GeocoderError::InvalidAssetHost { .. }) + )); + + let short_target = tempdir.path().join("short.db"); + let short_spec = fixture_spec(&bytes, TEST_URL); + let short_fetcher = BytesFetcher { + bytes: b"short".to_vec(), + calls: Cell::new(0), + }; + assert!(matches!( + ensure_geonames_asset_path_with_fetcher(&short_target, &short_spec, &short_fetcher), + Err(GeocoderError::InvalidAssetLength { .. }) + )); + + let wrong_hash_target = tempdir.path().join("wrong-hash.db"); + let wrong_hash_spec = GeoNamesAssetSpec { + sha256: "0000000000000000000000000000000000000000000000000000000000000000", + ..fixture_spec(&bytes, TEST_URL) + }; + let wrong_hash_fetcher = BytesFetcher { + bytes: bytes.clone(), + calls: Cell::new(0), + }; + assert!(matches!( + ensure_geonames_asset_path_with_fetcher( + &wrong_hash_target, + &wrong_hash_spec, + &wrong_hash_fetcher, + ), + Err(GeocoderError::InvalidAssetSha256 { .. }) + )); + + let sqlite_target = tempdir.path().join("corrupt-sqlite.db"); + let sqlite_bytes = padded_corrupt_bytes(bytes.len()); + fs::write(&sqlite_target, &sqlite_bytes).expect("write corrupt sqlite"); + let sqlite_spec = fixture_spec_with_hash(&sqlite_bytes, TEST_URL); + assert!(matches!( + validate_geonames_asset_file(&sqlite_target, &sqlite_spec), + Err(GeocoderError::InvalidAssetSqlite { .. }) + | Err(GeocoderError::InvalidAssetIntegrity { .. }) + )); + + let schema_target = tempdir.path().join("bad-schema.db"); + build_bad_schema_database(&schema_target); + let schema_bytes = fs::read(&schema_target).expect("bad schema bytes"); + let schema_spec = fixture_spec_with_hash(&schema_bytes, TEST_URL); + assert!(matches!( + validate_geonames_asset_file(&schema_target, &schema_spec), + Err(GeocoderError::InvalidAssetSchema { .. }) + )); + } + + #[test] + fn geonames_asset_lock_prevents_concurrent_install_writes() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let bytes = fixture_database_bytes(); + let spec = fixture_spec(&bytes, TEST_URL); + let target = tempdir.path().join("geonames-test.db"); + fs::create_dir_all(target.parent().expect("target parent")).expect("target parent"); + fs::write(lock_path_for_asset(&target), b"locked").expect("lock file"); + let fetcher = BytesFetcher { + bytes, + calls: Cell::new(0), + }; + + assert!(matches!( + ensure_geonames_asset_path_with_fetcher(&target, &spec, &fetcher), + Err(GeocoderError::AssetLockUnavailable { .. }) + )); + assert_eq!(fetcher.calls.get(), 0); + } + + fn fixture_database_bytes() -> Vec<u8> { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("fixture.db"); + build_fixture_database(&path); + fs::read(path).expect("fixture database bytes") + } + + fn fixture_spec(bytes: &[u8], url: &'static str) -> GeoNamesAssetSpec { + fixture_spec_with_hash(bytes, url) + } + + fn fixture_spec_with_hash(bytes: &[u8], url: &'static str) -> GeoNamesAssetSpec { + let digest = sha2::Sha256::digest(bytes); + let hash = Box::leak(hex::encode(digest).into_boxed_str()); + GeoNamesAssetSpec { + version: "test", + file_name: "geonames-test.db", + url, + allowed_host: GEONAMES_ASSET_HOST, + byte_size: bytes.len() as u64, + sha256: hash, + } + } + + fn padded_corrupt_bytes(len: usize) -> Vec<u8> { + let mut bytes = vec![0_u8; len.max(32)]; + bytes[..16].copy_from_slice(b"not sqlite bytes"); + bytes + } + + fn build_fixture_database(path: &Path) { + let conn = Connection::open(path).expect("open fixture db"); + conn.execute_batch(FIXTURE_SCHEMA).expect("fixture schema"); + conn.execute( + "INSERT INTO countries (id, name) VALUES (?1, ?2)", + ("FX", "Fixtureland"), + ) + .expect("insert country"); + conn.execute( + "INSERT INTO admin1 (country_id, id, name) VALUES (?1, ?2, ?3)", + ("FX", 1_i64, "Fixture Region"), + ) + .expect("insert admin1"); + conn.execute( + "INSERT INTO features (id, name, country_id, admin1_id) VALUES (?1, ?2, ?3, ?4)", + (1_i64, "Fixture Town", "FX", 1_i64), + ) + .expect("insert feature"); + conn.execute( + "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)", + (1_i64, 12.25_f64, -34.5_f64), + ) + .expect("insert coordinates"); + } + + fn build_bad_schema_database(path: &PathBuf) { + let conn = Connection::open(path).expect("open bad schema db"); + conn.execute_batch( + r#" + CREATE TABLE countries(id TEXT, name TEXT); + "#, + ) + .expect("bad schema"); + } + + const FIXTURE_SCHEMA: &str = r#" + CREATE TABLE countries(id TEXT, name TEXT); + CREATE TABLE admin1(country_id TEXT, id INTEGER, name TEXT); + CREATE TABLE features(id INTEGER, name TEXT, country_id TEXT, admin1_id INTEGER); + CREATE TABLE coordinates(feature_id INTEGER, latitude REAL, longitude REAL); + CREATE VIEW geonames AS + SELECT + features.id AS id, + features.name AS name, + admin1.id AS admin1_id, + admin1.name AS admin1_name, + countries.id AS country_id, + countries.name AS country_name, + coordinates.latitude AS latitude, + coordinates.longitude AS longitude + FROM features + JOIN countries ON features.country_id = countries.id + JOIN admin1 ON features.country_id = admin1.country_id + AND features.admin1_id = admin1.id + JOIN coordinates ON features.id = coordinates.feature_id; + "#; +} diff --git a/crates/geocoder/src/error.rs b/crates/geocoder/src/error.rs @@ -6,6 +6,49 @@ pub enum GeocoderError { Sqlite(#[from] rusqlite::Error), #[error("io error: {0}")] Io(#[from] std::io::Error), + #[error("invalid GeoNames asset URL {url}")] + InvalidAssetUrl { url: String }, + #[error("invalid GeoNames asset host for {url}: expected {expected_host}, got {actual_host}")] + InvalidAssetHost { + url: String, + expected_host: String, + actual_host: String, + }, + #[error("invalid GeoNames asset length at {path}: expected {expected}, got {actual}")] + InvalidAssetLength { + path: std::path::PathBuf, + expected: u64, + actual: u64, + }, + #[error("invalid GeoNames asset SHA-256 at {path}: expected {expected}, got {actual}")] + InvalidAssetSha256 { + path: std::path::PathBuf, + expected: String, + actual: String, + }, + #[error("invalid GeoNames asset SQLite database at {path}: {detail}")] + InvalidAssetSqlite { + path: std::path::PathBuf, + detail: String, + }, + #[error("invalid GeoNames asset SQLite integrity at {path}: {result}")] + InvalidAssetIntegrity { + path: std::path::PathBuf, + result: String, + }, + #[error("invalid GeoNames asset schema at {path}: {detail}")] + InvalidAssetSchema { + path: std::path::PathBuf, + detail: String, + }, + #[error("GeoNames asset lock is unavailable at {path}")] + AssetLockUnavailable { path: std::path::PathBuf }, + #[error("GeoNames asset download failed for {url}: {source}")] + AssetDownload { + url: String, + #[source] + source: reqwest::Error, + }, #[error("country center not found for {country_id}")] CountryCenterNotFound { country_id: String }, } diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs @@ -1,8 +1,9 @@ +use crate::asset::{GeoNamesAssetSpec, validate_geonames_asset_file}; use crate::error::GeocoderError; use crate::model::{ GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, }; -use rusqlite::{Connection, named_params}; +use rusqlite::{Connection, OpenFlags, named_params}; use std::io::Write; use std::path::Path; @@ -13,7 +14,7 @@ pub struct Geocoder { impl Geocoder { pub fn open_path<P: AsRef<Path>>(path: P) -> Result<Self, GeocoderError> { - let conn = Connection::open(path)?; + let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; Ok(Self { conn, _temp_path: None, @@ -25,13 +26,21 @@ impl Geocoder { temp.as_file_mut().write_all(bytes)?; let temp_path = temp.into_temp_path(); let path: &Path = temp_path.as_ref(); - let conn = Connection::open(path)?; + let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; Ok(Self { conn, _temp_path: Some(temp_path), }) } + pub fn open_verified_geonames_asset<P: AsRef<Path>>( + path: P, + spec: &GeoNamesAssetSpec, + ) -> Result<Self, GeocoderError> { + validate_geonames_asset_file(path.as_ref(), spec)?; + Self::open_path(path) + } + pub fn reverse( &self, point: GeocoderPoint, diff --git a/crates/geocoder/src/lib.rs b/crates/geocoder/src/lib.rs @@ -1,10 +1,20 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] +mod asset; mod error; mod geocoder; mod model; +pub use asset::{ + GEONAMES_1_0_ASSET, GEONAMES_ASSET_BYTE_SIZE, GEONAMES_ASSET_FILE_NAME, GEONAMES_ASSET_HOST, + GEONAMES_ASSET_SHA256, GEONAMES_ASSET_URL, GEONAMES_ASSET_VERSION, GeoNamesAssetFetcher, + GeoNamesAssetSpec, GeoNamesAssetState, GeoNamesAssetStatus, GeoNamesBlockingHttpFetcher, + default_geonames_asset_path_from_cache_root, ensure_default_geonames_asset_in_cache_root, + ensure_geonames_asset_in_cache_root_with_fetcher, ensure_geonames_asset_path_with_fetcher, + inspect_default_geonames_asset_in_cache_root, inspect_geonames_asset_path, + validate_geonames_asset_file, validate_geonames_asset_spec_source, +}; pub use error::GeocoderError; pub use geocoder::Geocoder; pub use model::{ diff --git a/crates/runtime_paths/src/conventions.rs b/crates/runtime_paths/src/conventions.rs @@ -8,6 +8,9 @@ use crate::{ pub const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml"; pub const DEFAULT_SERVICE_IDENTITY_FILE_NAME: &str = "identity.secret.json"; pub const DEFAULT_SHARED_IDENTITY_FILE_NAME: &str = "default.json"; +pub const DEFAULT_SHARED_GEONAMES_NAMESPACE_KIND: &str = "shared"; +pub const DEFAULT_SHARED_GEONAMES_NAMESPACE_VALUE: &str = "geonames"; +pub const DEFAULT_SHARED_GEONAMES_NAMESPACE: &str = "shared/geonames"; pub const DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_KIND: &str = "shared"; pub const DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_VALUE: &str = "local_events"; pub const DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE: &str = "shared/local_events"; @@ -71,6 +74,28 @@ pub fn default_shared_local_events_database_path_from_data_root( .join(DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME) } +#[must_use] +pub fn default_shared_geonames_root_from_cache_root(cache_root: impl AsRef<Path>) -> PathBuf { + cache_root + .as_ref() + .join(DEFAULT_SHARED_GEONAMES_NAMESPACE_KIND) + .join(DEFAULT_SHARED_GEONAMES_NAMESPACE_VALUE) +} + +#[must_use] +pub fn default_shared_geonames_database_file_name(version: &str) -> String { + format!("geonames-{version}.db") +} + +#[must_use] +pub fn default_shared_geonames_database_path_from_cache_root( + cache_root: impl AsRef<Path>, + version: &str, +) -> PathBuf { + default_shared_geonames_root_from_cache_root(cache_root) + .join(default_shared_geonames_database_file_name(version)) +} + pub fn default_shared_local_events_root_from_shared_accounts_data_root( shared_accounts_data_root: impl AsRef<Path>, ) -> Result<PathBuf, RadrootsRuntimePathsError> { @@ -100,9 +125,12 @@ mod tests { }; use super::{ - DEFAULT_SERVICE_IDENTITY_FILE_NAME, DEFAULT_SHARED_IDENTITY_FILE_NAME, - DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME, DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE, - default_namespaced_bootstrap_paths, default_shared_identity_path, + DEFAULT_SERVICE_IDENTITY_FILE_NAME, DEFAULT_SHARED_GEONAMES_NAMESPACE, + DEFAULT_SHARED_IDENTITY_FILE_NAME, DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME, + DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE, default_namespaced_bootstrap_paths, + default_shared_geonames_database_file_name, + default_shared_geonames_database_path_from_cache_root, + default_shared_geonames_root_from_cache_root, default_shared_identity_path, default_shared_local_events_database_path_from_data_root, default_shared_local_events_database_path_from_shared_accounts_data_root, default_shared_local_events_root_from_data_root, @@ -198,6 +226,26 @@ mod tests { } #[test] + fn shared_geonames_paths_use_canonical_shared_cache_namespace() { + let cache_root = PathBuf::from("/repo/infra/local/runtime/radroots/cache"); + + assert_eq!( + default_shared_geonames_root_from_cache_root(&cache_root), + cache_root.join(DEFAULT_SHARED_GEONAMES_NAMESPACE) + ); + assert_eq!( + default_shared_geonames_database_file_name("1.0"), + "geonames-1.0.db" + ); + assert_eq!( + default_shared_geonames_database_path_from_cache_root(&cache_root, "1.0"), + cache_root + .join(DEFAULT_SHARED_GEONAMES_NAMESPACE) + .join("geonames-1.0.db") + ); + } + + #[test] fn shared_local_events_paths_derive_from_shared_accounts_data_root() { let shared_accounts_data_root = PathBuf::from("/repo/infra/local/runtime/radroots/data/shared/accounts"); diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs @@ -10,10 +10,14 @@ pub mod service; pub use conventions::{ DEFAULT_CONFIG_FILE_NAME, DEFAULT_SERVICE_IDENTITY_FILE_NAME, - DEFAULT_SHARED_IDENTITY_FILE_NAME, DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME, - DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE, DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_KIND, - DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_VALUE, RadrootsBootstrapPaths, - default_namespaced_bootstrap_paths, default_shared_identity_path, + DEFAULT_SHARED_GEONAMES_NAMESPACE, DEFAULT_SHARED_GEONAMES_NAMESPACE_KIND, + DEFAULT_SHARED_GEONAMES_NAMESPACE_VALUE, DEFAULT_SHARED_IDENTITY_FILE_NAME, + DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME, DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE, + DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_KIND, DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_VALUE, + RadrootsBootstrapPaths, default_namespaced_bootstrap_paths, + default_shared_geonames_database_file_name, + default_shared_geonames_database_path_from_cache_root, + default_shared_geonames_root_from_cache_root, default_shared_identity_path, default_shared_local_events_database_path_from_data_root, default_shared_local_events_database_path_from_shared_accounts_data_root, default_shared_local_events_root_from_data_root,