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:
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,