lib

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

commit b096b3695614f3e7fbb2023d17723a48d6048dc7
parent 08d25db13554895b62385d5921a80d277be684de
Author: triesap <tyson@radroots.org>
Date:   Sat, 15 Aug 2026 03:50:29 +0000

geonames: adopt governed async sqlite access

- replace the remaining Rusqlite provider with caller-driven SQLx operations
- preserve read-only verification and deterministic query behavior
- update the reviewed public API and package-boundary contracts
- retire the final temporary Rusqlite dependency and audit exemption

Diffstat:
MCargo.lock | 52+++-------------------------------------------------
MCargo.toml | 1-
Mcontracts/api_baselines/radroots_geonames.txt | 16++++++++++------
Mcontracts/releases/sqlite_runtime.toml | 5+----
Mcrates/geonames/Cargo.toml | 7++++++-
Mcrates/geonames/README.md | 13++++++++-----
Mcrates/geonames/src/database.rs | 459++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/geonames/tests/package_boundary.rs | 30++++++++++++++++++++++++++++--
Msupply-chain/config.toml | 4----
Mtools/xtask/src/contract.rs | 3+--
10 files changed, 325 insertions(+), 265 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1225,18 +1225,6 @@ dependencies = [ ] [[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] name = "fancy-regex" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3135,9 +3123,11 @@ name = "radroots_geonames" version = "0.1.0-alpha" dependencies = [ "fs2", - "rusqlite", + "futures", "sha2", + "sqlx", "tempfile", + "tokio", "url", ] @@ -4014,30 +4004,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] name = "rust_decimal" version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4547,18 +4513,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" [[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] name = "sqlx" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -243,7 +243,6 @@ sp1-build = { version = "6.2.3" } sp1-sdk = { version = "6.2.3", default-features = false } sp1-zkvm = { version = "6.2.3" } reqwest = { version = "0.12", default-features = false } -rusqlite = { version = "0.39", default-features = false } rustls = { version = "0.23", default-features = false, features = [ "ring", "std", diff --git a/contracts/api_baselines/radroots_geonames.txt b/contracts/api_baselines/radroots_geonames.txt @@ -23,9 +23,11 @@ pub fn radroots_geonames::asset::official_asset_spec() -> radroots_geonames::ass pub mod radroots_geonames::database pub struct radroots_geonames::database::Geocoder impl radroots_geonames::database::Geocoder -pub fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error> -pub fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error> -pub fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error> +impl core::fmt::Debug for radroots_geonames::database::Geocoder +pub fn radroots_geonames::database::Geocoder::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_geonames::download #[non_exhaustive] pub enum radroots_geonames::download::FetchFailurePhase pub radroots_geonames::download::FetchFailurePhase::Cancelled @@ -136,9 +138,11 @@ pub fn radroots_geonames::model::Candidate::name(&self) -> &str pub const fn radroots_geonames::model::Candidate::point(&self) -> radroots_geonames::model::Point pub struct radroots_geonames::Geocoder impl radroots_geonames::database::Geocoder -pub fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error> -pub fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error> -pub fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error> +pub async fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error> +impl core::fmt::Debug for radroots_geonames::database::Geocoder +pub fn radroots_geonames::database::Geocoder::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_geonames::Point impl radroots_geonames::model::Point pub const fn radroots_geonames::model::Point::latitude(self) -> f64 diff --git a/contracts/releases/sqlite_runtime.toml b/contracts/releases/sqlite_runtime.toml @@ -36,7 +36,4 @@ status = "active" [migration] owner = "rcld-rshr-045" status = "in_progress" -temporary_direct_dependencies = [ - "radroots_geonames:rusqlite", - "workspace:rusqlite", -] +temporary_direct_dependencies = [] diff --git a/crates/geonames/Cargo.toml b/crates/geonames/Cargo.toml @@ -33,10 +33,15 @@ path = "src/lib.rs" [dependencies] fs2 = { workspace = true } -rusqlite = { workspace = true, features = ["bundled"] } +futures = { workspace = true } sha2 = { workspace = true } +sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] } tempfile = { workspace = true } +tokio = { workspace = true, features = ["sync"] } url = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + [lints] workspace = true diff --git a/crates/geonames/README.md b/crates/geonames/README.md @@ -6,9 +6,9 @@ read-only database lifecycle, and deterministic forward, reverse, feature, and country queries through provider-owned types. The crate does not choose cache or runtime paths, download during construction, -create an executor, spawn a worker, install a timer, expose SQLite or HTTP -client types, or define a generic geocoder SPI. Publication remains disabled -during the `0.1.0-alpha` refactor. +create an async runtime, spawn a crate-owned task, install a timer, expose +SQLite or HTTP client types, or define a generic geocoder SPI. Publication +remains disabled during the `0.1.0-alpha` refactor. The authoritative package charter is the [`radroots_geonames` section of the Release V1 specification](../../contracts/crates/release_v1/radroots_crates_release_v1.toml). @@ -64,7 +64,10 @@ an interruption before it leaves the existing destination unchanged. [`Geocoder::open`] accepts only an explicit regular file matching its [`AssetSpec`]. It opens SQLite read-only and query-only, runs an integrity check, and validates the required `geonames` and `coordinates` table columns. -Use [`Geocoder::close`] when an explicit terminal close result is required. +Opening, querying, and closing are caller-driven async operations. The caller +provides the async runtime; this crate does not create one or spawn crate-owned +tasks. Use [`Geocoder::close`] when an explicit terminal close result is +required. [`Geocoder::query`] supports: @@ -82,7 +85,7 @@ read through accessors. ## Errors, serialization, and side effects [`Error`] exposes stable package-owned categories without paths, SQL, hashes, -URLs, credentials, Rusqlite errors, or fetch-client errors. Host diagnostics +URLs, credentials, SQLx errors, or fetch-client errors. Host diagnostics should add their own path and transport context only at an access-controlled application boundary. diff --git a/crates/geonames/src/database.rs b/crates/geonames/src/database.rs @@ -1,11 +1,16 @@ //! Explicit GeoNames database lifecycle. use std::collections::BTreeSet; +use std::fmt; use std::path::Path; -use std::sync::Mutex; use std::time::Duration; -use rusqlite::{Connection, OpenFlags, Row, params}; +use futures::TryStreamExt; +use sqlx::{ + ConnectOptions, Connection as _, Row, SqliteConnection, + sqlite::{SqliteConnectOptions, SqliteRow}, +}; +use tokio::sync::Mutex; use crate::asset::verify_file; use crate::model::Country; @@ -27,15 +32,20 @@ const REQUIRED_COORDINATE_COLUMNS: &[&str] = &["feature_id", "latitude", "longit /// An opened, verified GeoNames database. /// /// The connection is read-only and serialized by this type. It owns no path -/// policy, migration authority, runtime, download, or background worker. -#[derive(Debug)] +/// policy, migration authority, runtime, download, or background task. pub struct Geocoder { - connection: Mutex<Connection>, + connection: Mutex<SqliteConnection>, +} + +impl fmt::Debug for Geocoder { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Geocoder").finish_non_exhaustive() + } } impl Geocoder { /// Opens an explicitly selected asset after complete identity and schema checks. - pub fn open(path: impl AsRef<Path>, spec: &AssetSpec) -> Result<Self, Error> { + pub async fn open(path: impl AsRef<Path>, spec: &AssetSpec) -> Result<Self, Error> { let path = path.as_ref(); let metadata = path .symlink_metadata() @@ -45,87 +55,90 @@ impl Geocoder { } verify_file(path, spec)?; - let connection = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .map_err(|_| Error::InvalidDatabase)?; - configure_connection(&connection)?; - validate_integrity(&connection)?; - validate_schema(&connection)?; + let options = SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .create_if_missing(false) + .immutable(true) + .busy_timeout(Duration::from_secs(5)) + .disable_statement_logging(); + let mut connection = SqliteConnection::connect_with(&options) + .await + .map_err(|_| Error::InvalidDatabase)?; + let validation = async { + configure_connection(&mut connection).await?; + validate_integrity(&mut connection).await?; + validate_schema(&mut connection).await + } + .await; + if let Err(error) = validation { + let _ = connection.close().await; + return Err(error); + } Ok(Self { connection: Mutex::new(connection), }) } /// Closes the database and reports a terminal SQLite close failure. - pub fn close(self) -> Result<(), Error> { - let connection = self - .connection - .into_inner() - .map_err(|_| Error::DatabaseConnectionUnavailable)?; + pub async fn close(self) -> Result<(), Error> { + let connection = self.connection.into_inner(); connection .close() + .await .map_err(|_| Error::DatabaseOperationFailed { operation: "close" }) } /// Executes one validated query with deterministic provider ordering. - pub fn query(&self, query: &Query) -> Result<QueryResult, Error> { - self.with_connection("query", |connection| match &query.kind { + pub async fn query(&self, query: &Query) -> Result<QueryResult, Error> { + let mut connection = self.connection.lock().await; + match &query.kind { QueryKind::Locality { locality, region, country, - } => query_locality( - connection, - locality, - region.as_deref(), - country.as_deref(), - query.limit(), - ), + } => { + query_locality( + &mut connection, + locality, + region.as_deref(), + country.as_deref(), + query.limit(), + ) + .await + } QueryKind::Freeform(query_text) => { let parsed = parse_freeform_query(query_text); query_locality( - connection, + &mut connection, &parsed.locality, parsed.region.as_deref(), parsed.country.as_deref(), query.limit(), ) + .await } - QueryKind::FeatureId(feature_id) => query_feature(connection, *feature_id), + QueryKind::FeatureId(feature_id) => query_feature(&mut connection, *feature_id).await, QueryKind::Reverse { point, radius_degrees, - } => query_reverse(connection, *point, *radius_degrees, query.limit()), - QueryKind::Countries => query_countries(connection, query.limit()), - }) - } - - fn with_connection<T>( - &self, - operation: &'static str, - use_connection: impl FnOnce(&Connection) -> rusqlite::Result<T>, - ) -> Result<T, Error> { - let connection = self - .connection - .lock() - .map_err(|_| Error::DatabaseConnectionUnavailable)?; - use_connection(&connection).map_err(|_| Error::DatabaseOperationFailed { operation }) + } => query_reverse(&mut connection, *point, *radius_degrees, query.limit()).await, + QueryKind::Countries => query_countries(&mut connection, query.limit()).await, + } } } -fn query_locality( - connection: &Connection, +async fn query_locality( + connection: &mut SqliteConnection, locality: &str, region: Option<&str>, country: Option<&str>, limit: usize, -) -> rusqlite::Result<QueryResult> { +) -> Result<QueryResult, Error> { let locality = normalize_name(locality); let country = country.map(normalize_name); let region = region.map(normalize_name); - let mut statement = connection.prepare( + let mut rows = sqlx::query( " SELECT id, name, CAST(admin1_id AS TEXT), admin1_name, country_id, country_name, latitude, longitude @@ -146,26 +159,33 @@ fn query_locality( THEN CAST(admin1_id AS TEXT) ELSE NULL END COLLATE BINARY, id ", - )?; - let candidates = statement - .query_map([locality], map_candidate)? - .collect::<Result<Vec<_>, _>>()? - .into_iter() - .filter(|candidate| { - country + ) + .bind(locality) + .fetch(&mut *connection); + let mut candidates = Vec::with_capacity(limit); + while let Some(row) = rows.try_next().await.map_err(query_failed)? { + let candidate = map_candidate(&row)?; + if country + .as_deref() + .is_none_or(|value| country_matches(&candidate, value)) + && region .as_deref() - .is_none_or(|value| country_matches(candidate, value)) - && region - .as_deref() - .is_none_or(|value| region_matches(candidate, value)) - }) - .take(limit) - .collect(); + .is_none_or(|value| region_matches(&candidate, value)) + { + candidates.push(candidate); + if candidates.len() == limit { + break; + } + } + } Ok(QueryResult::candidates(candidates)) } -fn query_feature(connection: &Connection, feature_id: i64) -> rusqlite::Result<QueryResult> { - let mut statement = connection.prepare( +async fn query_feature( + connection: &mut SqliteConnection, + feature_id: i64, +) -> Result<QueryResult, Error> { + let row = sqlx::query( " SELECT id, name, CAST(admin1_id AS TEXT), admin1_name, country_id, country_name, latitude, longitude @@ -173,23 +193,30 @@ fn query_feature(connection: &Connection, feature_id: i64) -> rusqlite::Result<Q WHERE id = ?1 LIMIT 1 ", - )?; - let candidates = statement - .query_map([feature_id], map_candidate)? - .collect::<Result<Vec<_>, _>>()?; + ) + .bind(feature_id) + .fetch_optional(connection) + .await + .map_err(query_failed)?; + let candidates = row + .as_ref() + .map(map_candidate) + .transpose()? + .into_iter() + .collect(); Ok(QueryResult::candidates(candidates)) } -fn query_reverse( - connection: &Connection, +async fn query_reverse( + connection: &mut SqliteConnection, point: Point, radius_degrees: f64, limit: usize, -) -> rusqlite::Result<QueryResult> { +) -> Result<QueryResult, Error> { let latitude = point.latitude(); let longitude = point.longitude(); let longitude_weight = latitude.to_radians().cos().powi(2); - let mut statement = connection.prepare( + let rows = sqlx::query( " SELECT g.id, g.name, CAST(g.admin1_id AS TEXT), g.admin1_name, g.country_id, g.country_name, g.latitude, g.longitude @@ -218,19 +245,27 @@ fn query_reverse( g.id LIMIT ?5 ", - )?; - let limit = i64::try_from(limit).unwrap_or(i64::MAX); - let candidates = statement - .query_map( - params![latitude, longitude, radius_degrees, longitude_weight, limit], - map_candidate, - )? + ) + .bind(latitude) + .bind(longitude) + .bind(radius_degrees) + .bind(longitude_weight) + .bind(i64::try_from(limit).unwrap_or(i64::MAX)) + .fetch_all(connection) + .await + .map_err(query_failed)?; + let candidates = rows + .iter() + .map(map_candidate) .collect::<Result<Vec<_>, _>>()?; Ok(QueryResult::candidates(candidates)) } -fn query_countries(connection: &Connection, limit: usize) -> rusqlite::Result<QueryResult> { - let mut statement = connection.prepare( +async fn query_countries( + connection: &mut SqliteConnection, + limit: usize, +) -> Result<QueryResult, Error> { + let rows = sqlx::query( " SELECT country_id, country_name, AVG(latitude), AVG(longitude) FROM geonames @@ -238,41 +273,48 @@ fn query_countries(connection: &Connection, limit: usize) -> rusqlite::Result<Qu ORDER BY lower(country_id), lower(coalesce(country_name, '')) LIMIT ?1 ", - )?; - let limit = i64::try_from(limit).unwrap_or(i64::MAX); - let countries = statement - .query_map([limit], map_country)? + ) + .bind(i64::try_from(limit).unwrap_or(i64::MAX)) + .fetch_all(connection) + .await + .map_err(query_failed)?; + let countries = rows + .iter() + .map(map_country) .collect::<Result<Vec<_>, _>>()?; Ok(QueryResult::countries(countries)) } -fn map_candidate(row: &Row<'_>) -> rusqlite::Result<Candidate> { - let feature_id = row.get::<_, i64>(0)?; - let feature_id = u64::try_from(feature_id) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, feature_id))?; - let latitude = row.get::<_, f64>(6)?; - let longitude = row.get::<_, f64>(7)?; - let point = Point::new(latitude, longitude).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Real, Box::new(error)) - })?; +fn map_candidate(row: &SqliteRow) -> Result<Candidate, Error> { + let feature_id = row.try_get::<i64, _>(0).map_err(query_failed)?; + let feature_id = u64::try_from(feature_id).map_err(|_| query_failed(()))?; + let latitude = row.try_get::<f64, _>(6).map_err(query_failed)?; + let longitude = row.try_get::<f64, _>(7).map_err(query_failed)?; + let point = Point::new(latitude, longitude).map_err(|_| query_failed(()))?; Ok(Candidate::from_provider_row( feature_id, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, + row.try_get(1).map_err(query_failed)?, + row.try_get(2).map_err(query_failed)?, + row.try_get(3).map_err(query_failed)?, + row.try_get(4).map_err(query_failed)?, + row.try_get(5).map_err(query_failed)?, + point, + )) +} + +fn map_country(row: &SqliteRow) -> Result<Country, Error> { + let latitude = row.try_get::<f64, _>(2).map_err(query_failed)?; + let longitude = row.try_get::<f64, _>(3).map_err(query_failed)?; + let point = Point::new(latitude, longitude).map_err(|_| query_failed(()))?; + Ok(Country::from_provider_row( + row.try_get(0).map_err(query_failed)?, + row.try_get(1).map_err(query_failed)?, point, )) } -fn map_country(row: &Row<'_>) -> rusqlite::Result<Country> { - let latitude = row.get::<_, f64>(2)?; - let longitude = row.get::<_, f64>(3)?; - let point = Point::new(latitude, longitude).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Real, Box::new(error)) - })?; - Ok(Country::from_provider_row(row.get(0)?, row.get(1)?, point)) +fn query_failed<T>(_source: T) -> Error { + Error::DatabaseOperationFailed { operation: "query" } } struct ParsedQuery { @@ -429,61 +471,67 @@ fn region_aliases(country_id: &str) -> &'static [(&'static str, &'static str)] { } } -fn configure_connection(connection: &Connection) -> Result<(), Error> { - connection - .busy_timeout(Duration::from_secs(5)) - .and_then(|()| connection.pragma_update(None, "query_only", true)) - .and_then(|()| connection.pragma_update(None, "trusted_schema", false)) - .map_err(|_| Error::InvalidDatabase) +async fn configure_connection(connection: &mut SqliteConnection) -> Result<(), Error> { + sqlx::query("PRAGMA query_only = ON") + .execute(&mut *connection) + .await + .map_err(|_| Error::InvalidDatabase)?; + sqlx::query("PRAGMA trusted_schema = OFF") + .execute(&mut *connection) + .await + .map_err(|_| Error::InvalidDatabase)?; + let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only") + .fetch_one(&mut *connection) + .await + .map_err(|_| Error::InvalidDatabase)?; + let trusted_schema = sqlx::query_scalar::<_, i64>("PRAGMA trusted_schema") + .fetch_one(connection) + .await + .map_err(|_| Error::InvalidDatabase)?; + if query_only != 1 || trusted_schema != 0 { + return Err(Error::InvalidDatabase); + } + Ok(()) } -fn validate_integrity(connection: &Connection) -> Result<(), Error> { - let result = connection - .query_row("PRAGMA quick_check(1)", [], |row| row.get::<_, String>(0)) +async fn validate_integrity(connection: &mut SqliteConnection) -> Result<(), Error> { + let rows = sqlx::query_scalar::<_, String>("PRAGMA quick_check(1)") + .fetch_all(connection) + .await .map_err(|_| Error::InvalidDatabase)?; - if result != "ok" { + if rows.len() != 1 || rows[0] != "ok" { return Err(Error::InvalidDatabase); } Ok(()) } -fn validate_schema(connection: &Connection) -> Result<(), Error> { - validate_table( - connection, - "geonames", - REQUIRED_GEONAMES_COLUMNS, - "PRAGMA table_info('geonames')", - )?; - validate_table( - connection, - "coordinates", - REQUIRED_COORDINATE_COLUMNS, - "PRAGMA table_info('coordinates')", - ) +async fn validate_schema(connection: &mut SqliteConnection) -> Result<(), Error> { + validate_table(connection, "geonames", REQUIRED_GEONAMES_COLUMNS).await?; + validate_table(connection, "coordinates", REQUIRED_COORDINATE_COLUMNS).await } -fn validate_table( - connection: &Connection, +async fn validate_table( + connection: &mut SqliteConnection, table: &str, required_columns: &[&str], - column_pragma: &str, ) -> Result<(), Error> { - connection - .query_row( - "SELECT 1 FROM sqlite_schema WHERE name = ?1 AND type = 'table'", - [table], - |_| Ok(()), - ) - .map_err(|_| Error::InvalidDatabaseSchema)?; + let table_exists = sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM sqlite_schema WHERE name = ?1 AND type = 'table' LIMIT 1", + ) + .bind(table) + .fetch_optional(&mut *connection) + .await + .map_err(|_| Error::InvalidDatabaseSchema)?; + if table_exists != Some(1) { + return Err(Error::InvalidDatabaseSchema); + } - let mut statement = connection - .prepare(column_pragma) - .map_err(|_| Error::InvalidDatabaseSchema)?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|_| Error::InvalidDatabaseSchema)? - .collect::<Result<BTreeSet<_>, _>>() + let columns = sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info(?1)") + .bind(table) + .fetch_all(connection) + .await .map_err(|_| Error::InvalidDatabaseSchema)?; + let columns = columns.into_iter().collect::<BTreeSet<_>>(); if required_columns .iter() .any(|column| !columns.contains(*column)) @@ -497,8 +545,8 @@ fn validate_table( mod tests { use std::fs; - use rusqlite::Connection; use sha2::{Digest, Sha256}; + use sqlx::{ConnectOptions, Connection as _, SqliteConnection, sqlite::SqliteConnectOptions}; use tempfile::{TempDir, tempdir}; use super::{ @@ -507,14 +555,21 @@ mod tests { }; use crate::{AssetSpec, Candidate, Error, Point}; - fn database_fixture(schema: &str) -> (TempDir, std::path::PathBuf, AssetSpec) { + async fn database_fixture(schema: &str) -> (TempDir, std::path::PathBuf, AssetSpec) { let directory = tempdir().expect("tempdir"); let path = directory.path().join("geonames-test.db"); - let connection = Connection::open(&path).expect("create fixture database"); - connection - .execute_batch(schema) + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .disable_statement_logging(); + let mut connection = SqliteConnection::connect_with(&options) + .await + .expect("create fixture database"); + sqlx::raw_sql(sqlx::AssertSqlSafe(schema)) + .execute(&mut connection) + .await .expect("install fixture schema"); - connection.close().expect("close fixture writer"); + connection.close().await.expect("close fixture writer"); let bytes = fs::read(&path).expect("read fixture"); let spec = AssetSpec::new( "test-v1", @@ -566,29 +621,41 @@ mod tests { " } - #[test] - fn verified_governed_database_opens_read_only_and_closes_explicitly() { - let (_directory, path, spec) = database_fixture(governed_schema()); - let geocoder = Geocoder::open(path.clone(), &spec).expect("open verified database"); - let connection = geocoder.connection.lock().expect("connection lock"); - let count = connection - .query_row("SELECT COUNT(*) FROM geonames", [], |row| { - row.get::<_, i64>(0) - }) + #[tokio::test(flavor = "current_thread")] + async fn verified_governed_database_opens_read_only_and_closes_explicitly() { + let (_directory, path, spec) = database_fixture(governed_schema()).await; + let geocoder = Geocoder::open(path.clone(), &spec) + .await + .expect("open verified database"); + let mut connection = geocoder.connection.lock().await; + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM geonames") + .fetch_one(&mut *connection) + .await .expect("query fixture"); assert_eq!(count, 8); - assert!(matches!( - connection.execute("DELETE FROM geonames", []), - Err(rusqlite::Error::SqliteFailure(_, _)) - )); + let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only") + .fetch_one(&mut *connection) + .await + .expect("read query-only policy"); + let trusted_schema = sqlx::query_scalar::<_, i64>("PRAGMA trusted_schema") + .fetch_one(&mut *connection) + .await + .expect("read trusted-schema policy"); + assert_eq!((query_only, trusted_schema), (1, 0)); + assert!( + sqlx::query("DELETE FROM geonames") + .execute(&mut *connection) + .await + .is_err() + ); drop(connection); - geocoder.close().expect("explicit close"); + geocoder.close().await.expect("explicit close"); } - #[test] - fn forward_and_feature_queries_preserve_text_ids_and_stable_order() { - let (_directory, path, spec) = database_fixture(governed_schema()); - let geocoder = Geocoder::open(path, &spec).expect("geocoder"); + #[tokio::test(flavor = "current_thread")] + async fn forward_and_feature_queries_preserve_text_ids_and_stable_order() { + let (_directory, path, spec) = database_fixture(governed_schema()).await; + let geocoder = Geocoder::open(path, &spec).await.expect("geocoder"); let structured = crate::Query::locality("Victoria") .expect("locality") @@ -596,7 +663,7 @@ mod tests { .expect("region") .with_country("Canada") .expect("country"); - let result = geocoder.query(&structured).expect("structured query"); + let result = geocoder.query(&structured).await.expect("structured query"); let candidates = result.as_candidates().expect("candidate result"); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].feature_id(), 6_174_041); @@ -610,6 +677,7 @@ mod tests { assert_eq!( geocoder .query(&freeform) + .await .expect("freeform query") .as_candidates() .expect("candidates")[0] @@ -623,6 +691,7 @@ mod tests { .expect("limit"); let candidates = geocoder .query(&ambiguous) + .await .expect("ambiguous query") .as_candidates() .expect("candidates") @@ -639,6 +708,7 @@ mod tests { assert_eq!( geocoder .query(&feature) + .await .expect("feature result") .as_candidates() .expect("candidates")[0] @@ -647,10 +717,10 @@ mod tests { ); } - #[test] - fn reverse_and_country_queries_are_bounded_and_deterministic() { - let (_directory, path, spec) = database_fixture(governed_schema()); - let geocoder = Geocoder::open(path, &spec).expect("geocoder"); + #[tokio::test(flavor = "current_thread")] + async fn reverse_and_country_queries_are_bounded_and_deterministic() { + let (_directory, path, spec) = database_fixture(governed_schema()).await; + let geocoder = Geocoder::open(path, &spec).await.expect("geocoder"); let reverse = crate::Query::reverse(crate::Point::new(49.0, -124.0).expect("point")) .with_radius_degrees(0.1) .expect("radius") @@ -658,6 +728,7 @@ mod tests { .expect("limit"); let candidates = geocoder .query(&reverse) + .await .expect("reverse result") .as_candidates() .expect("candidates") @@ -672,6 +743,7 @@ mod tests { let countries = geocoder .query(&crate::Query::countries()) + .await .expect("country result"); let countries = countries.as_countries().expect("countries"); assert_eq!( @@ -692,6 +764,7 @@ mod tests { assert_eq!( geocoder .query(&dateline) + .await .expect("dateline result") .as_candidates() .expect("candidates") @@ -709,6 +782,7 @@ mod tests { assert_eq!( geocoder .query(&pole) + .await .expect("pole result") .as_candidates() .expect("candidates") @@ -719,8 +793,8 @@ mod tests { ); } - #[test] - fn corrupt_bytes_and_incomplete_schema_fail_closed() { + #[tokio::test(flavor = "current_thread")] + async fn corrupt_bytes_and_incomplete_schema_fail_closed() { let directory = tempdir().expect("tempdir"); let path = directory.path().join("geonames-test.db"); fs::write(&path, b"not sqlite").expect("write corrupt fixture"); @@ -734,27 +808,28 @@ mod tests { ) .expect("corrupt spec"); assert!(matches!( - Geocoder::open(&path, &corrupt_spec), + Geocoder::open(&path, &corrupt_spec).await, Err(Error::InvalidDatabase) )); - let (_directory, path, spec) = database_fixture("CREATE TABLE geonames (id INTEGER);"); + let (_directory, path, spec) = + database_fixture("CREATE TABLE geonames (id INTEGER);").await; assert!(matches!( - Geocoder::open(path, &spec), + Geocoder::open(path, &spec).await, Err(Error::InvalidDatabaseSchema) )); } #[cfg(unix)] - #[test] - fn verified_database_open_rejects_symlink_assets() { + #[tokio::test(flavor = "current_thread")] + async fn verified_database_open_rejects_symlink_assets() { use std::os::unix::fs::symlink; - let (directory, path, spec) = database_fixture(governed_schema()); + let (directory, path, spec) = database_fixture(governed_schema()).await; let link = directory.path().join("linked.db"); symlink(path, &link).expect("asset symlink"); assert!(matches!( - Geocoder::open(link, &spec), + Geocoder::open(link, &spec).await, Err(Error::UnsafeAssetDestination) )); } @@ -817,8 +892,8 @@ mod tests { assert_eq!(many.country.as_deref(), Some("CA")); } - #[test] - fn database_open_and_row_mapping_fail_closed_for_invalid_shapes() { + #[tokio::test(flavor = "current_thread")] + async fn database_open_and_row_mapping_fail_closed_for_invalid_shapes() { let directory = tempdir().expect("tempdir"); let placeholder = AssetSpec::new( "v1", @@ -830,7 +905,7 @@ mod tests { ) .expect("placeholder spec"); assert!(matches!( - Geocoder::open(directory.path(), &placeholder), + Geocoder::open(directory.path(), &placeholder).await, Err(Error::UnsafeAssetDestination) )); @@ -838,11 +913,13 @@ mod tests { "(6174041, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)", "(-1, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)", ); - let (_directory, path, spec) = database_fixture(&invalid_row_schema); - let geocoder = Geocoder::open(path, &spec).expect("open negative-id fixture"); + let (_directory, path, spec) = database_fixture(&invalid_row_schema).await; + let geocoder = Geocoder::open(path, &spec) + .await + .expect("open negative-id fixture"); let query = crate::Query::locality("Victoria").expect("query"); assert!(matches!( - geocoder.query(&query), + geocoder.query(&query).await, Err(Error::DatabaseOperationFailed { operation: "query" }) )); } diff --git a/crates/geonames/tests/package_boundary.rs b/crates/geonames/tests/package_boundary.rs @@ -76,6 +76,9 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "Geocoder::open", "Geocoder::query", "Geocoder::close", + "pub async fn radroots_geonames::database::Geocoder::close", + "pub async fn radroots_geonames::database::Geocoder::open", + "pub async fn radroots_geonames::database::Geocoder::query", ] { assert!( PUBLIC_API.contains(required), @@ -111,8 +114,12 @@ fn provider_source_has_no_hidden_runtime_path_or_download_implementation() { for forbidden in [ "radroots_runtime_paths", "reqwest::", - "tokio::", - "sqlx::", + "tokio::runtime", + "Runtime::new", + "Builder::new_", + "tokio::spawn", + "spawn_blocking", + "pub use sqlx", "std::env::", "directories::", "test-fixture-geonames-asset", @@ -122,6 +129,25 @@ fn provider_source_has_no_hidden_runtime_path_or_download_implementation() { "provider source contains `{forbidden}`" ); } + for required in [ + "use sqlx::{", + "use tokio::sync::Mutex;", + "pub async fn open", + "pub async fn query", + "pub async fn close", + ] { + assert!( + source.contains(required), + "provider source is missing `{required}`" + ); + } + for required in [ + "caller-driven async operations", + "provides the async runtime", + "does not create one or spawn crate-owned", + ] { + assert!(README.contains(required), "README is missing `{required}`"); + } } #[test] diff --git a/supply-chain/config.toml b/supply-chain/config.toml @@ -1657,10 +1657,6 @@ criteria = "safe-to-deploy" version = "0.1.1" criteria = "safe-to-deploy" -[[exemptions.rusqlite]] -version = "0.39.0" -criteria = "safe-to-deploy" - [[exemptions.rust_decimal]] version = "1.40.0" criteria = "safe-to-deploy" diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -4162,8 +4162,7 @@ fn validate_sqlite_runtime_contract(workspace_root: &Path) -> Result<(), String> const FORBIDDEN_HIGH_LEVEL_DEPENDENCIES: [&str; 6] = [ "diesel", "refinery", "rusqlite", "sea-orm", "sqlite", "sqlite3", ]; - const TEMPORARY_DIRECT_DEPENDENCIES: [&str; 2] = - ["radroots_geonames:rusqlite", "workspace:rusqlite"]; + const TEMPORARY_DIRECT_DEPENDENCIES: [&str; 0] = []; let contract = parse_toml::<SqliteRuntimeContract>( &workspace_root.join(SQLITE_RUNTIME_CONTRACT_RELATIVE),