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