lib

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

commit b5221535081953bec9a963c1c3aa4b544c329582
parent de373374ca5d2ef3408ba5f62e784bb754308065
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 09:32:44 +0000

geonames: harden asset specification and acquisition

- pin the official asset size and SHA-256 identity
- require exact HTTPS authority without credential-bearing URLs
- stream into bounded same-directory staging files
- atomically preserve or replace only verified destinations

Diffstat:
MCargo.lock | 6++++++
Mcrates/geonames/Cargo.toml | 6++++++
Mcrates/geonames/src/asset.rs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/geonames/src/download.rs | 336++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/geonames/src/error.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
5 files changed, 589 insertions(+), 24 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4624,6 +4624,12 @@ dependencies = [ [[package]] name = "radroots_geonames" version = "0.1.0-alpha" +dependencies = [ + "fs2", + "sha2", + "tempfile", + "url", +] [[package]] name = "radroots_identity" diff --git a/crates/geonames/Cargo.toml b/crates/geonames/Cargo.toml @@ -15,5 +15,11 @@ publish = false name = "radroots_geonames" path = "src/lib.rs" +[dependencies] +fs2 = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } +url = { workspace = true } + [lints] workspace = true diff --git a/crates/geonames/src/asset.rs b/crates/geonames/src/asset.rs @@ -1,13 +1,48 @@ //! Host-supplied GeoNames asset identity and passive status. +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use sha2::{Digest, Sha256}; +use url::Url; + use crate::Error; +/// Version of the first governed Radroots GeoNames asset. +pub const OFFICIAL_ASSET_VERSION: &str = "1.0"; +/// File name of the first governed Radroots GeoNames asset. +pub const OFFICIAL_ASSET_FILE_NAME: &str = "geonames-1.0.db"; +/// HTTPS source of the first governed Radroots GeoNames asset. +pub const OFFICIAL_ASSET_SOURCE: &str = "https://assets.radroots.io/data/geonames/geonames-1.0.db"; +/// Exact byte size of the first governed Radroots GeoNames asset. +pub const OFFICIAL_ASSET_BYTE_SIZE: u64 = 12_951_552; +/// Exact SHA-256 of the first governed Radroots GeoNames asset. +pub const OFFICIAL_ASSET_SHA256: [u8; 32] = [ + 0x6c, 0xa5, 0xf1, 0xa3, 0x24, 0xde, 0x02, 0x92, 0x2d, 0x40, 0xb1, 0xff, 0x33, 0xee, 0xdf, 0x3a, + 0x5a, 0x13, 0x3c, 0x97, 0x8d, 0xe9, 0x21, 0xee, 0xe5, 0x13, 0x0a, 0x0c, 0x78, 0x76, 0x07, 0x9c, +]; + +/// Returns the immutable specification for the governed Radroots asset. +#[must_use] +pub fn official_asset_spec() -> AssetSpec { + AssetSpec { + version: OFFICIAL_ASSET_VERSION.to_owned(), + file_name: OFFICIAL_ASSET_FILE_NAME.to_owned(), + source: OFFICIAL_ASSET_SOURCE.to_owned(), + allowed_host: "assets.radroots.io".to_owned(), + byte_size: OFFICIAL_ASSET_BYTE_SIZE, + sha256: OFFICIAL_ASSET_SHA256, + } +} + /// The expected identity of one immutable GeoNames database asset. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AssetSpec { version: String, file_name: String, source: String, + allowed_host: String, byte_size: u64, sha256: [u8; 32], } @@ -18,6 +53,7 @@ impl AssetSpec { version: impl Into<String>, file_name: impl Into<String>, source: impl Into<String>, + allowed_host: impl Into<String>, byte_size: u64, sha256: [u8; 32], ) -> Result<Self, Error> { @@ -32,9 +68,11 @@ impl AssetSpec { } let source = source.into(); - if !is_normalized_non_empty(&source) { + let allowed_host = allowed_host.into(); + if !is_normalized_non_empty(&source) || !is_normalized_non_empty(&allowed_host) { return Err(Error::InvalidAssetSource); } + validate_source(&source, &allowed_host)?; if byte_size == 0 { return Err(Error::InvalidAssetByteSize); } @@ -43,6 +81,7 @@ impl AssetSpec { version, file_name, source, + allowed_host, byte_size, sha256, }) @@ -60,12 +99,18 @@ impl AssetSpec { &self.file_name } - /// Returns the host-supplied source identifier. + /// Returns the explicit HTTPS source. #[must_use] pub fn source(&self) -> &str { &self.source } + /// Returns the exact HTTPS host allowed for acquisition. + #[must_use] + pub fn allowed_host(&self) -> &str { + &self.allowed_host + } + /// Returns the exact expected byte size. #[must_use] pub const fn byte_size(&self) -> u64 { @@ -91,17 +136,96 @@ pub enum AssetStatus { Invalid, } +/// Inspects an explicit path without creating, repairing, or downloading it. +pub fn inspect(path: impl AsRef<Path>, spec: &AssetSpec) -> Result<AssetStatus, Error> { + let path = path.as_ref(); + let metadata = match path.symlink_metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AssetStatus::Missing); + } + Err(error) => return Err(io_error("inspect asset metadata", error)), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(Error::UnsafeAssetDestination); + } + match verify_file(path, spec) { + Ok(()) => Ok(AssetStatus::Available), + Err(Error::AssetSizeMismatch { .. } | Error::AssetHashMismatch) => Ok(AssetStatus::Invalid), + Err(error) => Err(error), + } +} + +pub(crate) fn verify_file(path: &Path, spec: &AssetSpec) -> Result<(), Error> { + let mut file = File::open(path).map_err(|error| io_error("open asset", error))?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut observed = 0_u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| io_error("read asset", error))?; + if read == 0 { + break; + } + observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX)); + if observed > spec.byte_size { + return Err(Error::AssetSizeMismatch { + expected: spec.byte_size, + actual: observed, + }); + } + digest.update(&buffer[..read]); + } + if observed != spec.byte_size { + return Err(Error::AssetSizeMismatch { + expected: spec.byte_size, + actual: observed, + }); + } + let actual: [u8; 32] = digest.finalize().into(); + if actual != spec.sha256 { + return Err(Error::AssetHashMismatch); + } + Ok(()) +} + +pub(crate) fn io_error(operation: &'static str, error: std::io::Error) -> Error { + Error::Io { + operation, + kind: error.kind(), + } +} + fn is_normalized_non_empty(value: &str) -> bool { !value.is_empty() && value.trim() == value } fn is_safe_file_name(value: &str) -> bool { - is_normalized_non_empty(value) && value != "." && value != ".." && !value.contains(['/', '\\']) + is_normalized_non_empty(value) + && value != "." + && value != ".." + && !value.contains(['/', '\\', ':', '\0']) +} + +fn validate_source(source: &str, allowed_host: &str) -> Result<(), Error> { + let parsed = Url::parse(source).map_err(|_| Error::UntrustedAssetSource)?; + let trusted = parsed.scheme() == "https" + && parsed.host_str() == Some(allowed_host) + && parsed.port_or_known_default() == Some(443) + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none(); + if !trusted { + return Err(Error::UntrustedAssetSource); + } + Ok(()) } #[cfg(test)] mod tests { - use super::{AssetSpec, AssetStatus}; + use super::{AssetSpec, AssetStatus, OFFICIAL_ASSET_SHA256, official_asset_spec}; use crate::Error; fn spec() -> AssetSpec { @@ -109,6 +233,7 @@ mod tests { "2026-08", "geonames-2026-08.db", "https://assets.example/geonames-2026-08.db", + "assets.example", 42, [7; 32], ) @@ -121,28 +246,69 @@ mod tests { assert_eq!(spec.version(), "2026-08"); assert_eq!(spec.file_name(), "geonames-2026-08.db"); assert_eq!(spec.source(), "https://assets.example/geonames-2026-08.db"); + assert_eq!(spec.allowed_host(), "assets.example"); assert_eq!(spec.byte_size(), 42); assert_eq!(spec.sha256(), &[7; 32]); } #[test] - fn asset_spec_rejects_ambient_or_unsafe_values() { + fn official_asset_identity_is_byte_pinned_without_fixture_features() { + let spec = official_asset_spec(); + assert_eq!(spec.version(), "1.0"); + assert_eq!(spec.file_name(), "geonames-1.0.db"); + assert_eq!(spec.allowed_host(), "assets.radroots.io"); + assert_eq!(spec.byte_size(), 12_951_552); + assert_eq!(spec.sha256(), &OFFICIAL_ASSET_SHA256); + } + + #[test] + fn asset_spec_rejects_ambient_unsafe_or_untrusted_values() { + let valid_source = "https://assets.example/a"; assert_eq!( - AssetSpec::new(" ", "asset.db", "source", 1, [0; 32]), + AssetSpec::new(" ", "asset.db", valid_source, "assets.example", 1, [0; 32]), Err(Error::InvalidAssetVersion) ); assert_eq!( - AssetSpec::new("v1", "../asset.db", "source", 1, [0; 32]), + AssetSpec::new( + "v1", + "../asset.db", + valid_source, + "assets.example", + 1, + [0; 32] + ), Err(Error::InvalidAssetFileName) ); assert_eq!( - AssetSpec::new("v1", "asset.db", " source", 1, [0; 32]), + AssetSpec::new("v1", "asset.db", " source", "assets.example", 1, [0; 32]), Err(Error::InvalidAssetSource) ); assert_eq!( - AssetSpec::new("v1", "asset.db", "source", 0, [0; 32]), + AssetSpec::new("v1", "asset.db", valid_source, "assets.example", 0, [0; 32]), Err(Error::InvalidAssetByteSize) ); + assert_eq!( + AssetSpec::new( + "v1", + "asset.db", + "http://assets.example/a", + "assets.example", + 1, + [0; 32] + ), + Err(Error::UntrustedAssetSource) + ); + assert_eq!( + AssetSpec::new( + "v1", + "asset.db", + "https://other.example/a", + "assets.example", + 1, + [0; 32] + ), + Err(Error::UntrustedAssetSource) + ); } #[test] diff --git a/crates/geonames/src/download.rs b/crates/geonames/src/download.rs @@ -1,4 +1,334 @@ //! Explicit, caller-driven asset acquisition. -//! -//! Fetch and installation behavior is introduced by the acquisition -//! checkpoint. Importing this module never starts network or filesystem work. + +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use tempfile::NamedTempFile; + +use crate::asset::{inspect, io_error, verify_file}; +use crate::{AssetSpec, AssetStatus, Error}; + +/// Stable phase attached to an injected fetch failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum FetchFailurePhase { + /// Establishing the source connection or opening the source. + Connect, + /// Receiving source metadata or an initial response. + Response, + /// Streaming source bytes. + Read, + /// Caller-requested cancellation. + Cancelled, +} + +impl fmt::Display for FetchFailurePhase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Connect => "connect", + Self::Response => "response", + Self::Read => "read", + Self::Cancelled => "cancellation", + }) + } +} + +/// Explicit byte source supplied by the host. +/// +/// Implementations own transport execution and cancellation. Failure details +/// cannot enter this API, preventing source URLs or credentials from leaking. +pub trait Fetcher { + /// Streams the requested source into the bounded destination. + fn fetch(&self, source: &str, destination: &mut dyn Write) -> Result<(), Error>; +} + +/// Installs a verified asset into a caller-selected existing directory. +/// +/// The fetcher is invoked only when the final asset is absent or invalid. The +/// existing destination remains untouched until a fully written staging file +/// passes exact length and SHA-256 validation. +pub fn acquire( + directory: impl AsRef<Path>, + spec: &AssetSpec, + fetcher: &dyn Fetcher, +) -> Result<AssetStatus, Error> { + let directory = safe_directory(directory.as_ref())?; + let destination = directory.join(spec.file_name()); + match inspect(&destination, spec)? { + AssetStatus::Available => return Ok(AssetStatus::Available), + AssetStatus::Missing | AssetStatus::Invalid => {} + } + + reject_symlink(&destination)?; + let lock_path = directory.join(format!(".{}.lock", spec.file_name())); + reject_symlink(&lock_path)?; + let lock = open_lock(&lock_path)?; + lock.try_lock_exclusive() + .map_err(|_| Error::AssetDestinationBusy)?; + + if inspect(&destination, spec)? == AssetStatus::Available { + return Ok(AssetStatus::Available); + } + + let mut staging = NamedTempFile::new_in(&directory) + .map_err(|error| io_error("create asset staging file", error))?; + let (fetch_result, observed, overflowed) = { + let mut writer = BoundedWriter::new(staging.as_file_mut(), spec.byte_size()); + let fetch_result = fetcher.fetch(spec.source(), &mut writer); + (fetch_result, writer.observed, writer.overflowed) + }; + if overflowed { + return Err(Error::AssetSizeMismatch { + expected: spec.byte_size(), + actual: spec.byte_size().saturating_add(1), + }); + } + fetch_result?; + if observed != spec.byte_size() { + return Err(Error::AssetSizeMismatch { + expected: spec.byte_size(), + actual: observed, + }); + } + staging + .as_file_mut() + .sync_all() + .map_err(|error| io_error("sync asset staging file", error))?; + verify_file(staging.path(), spec)?; + reject_symlink(&destination)?; + staging + .persist(&destination) + .map_err(|error| io_error("finalize asset", error.error))?; + sync_directory(&directory)?; + verify_file(&destination, spec)?; + Ok(AssetStatus::Available) +} + +fn safe_directory(directory: &Path) -> Result<PathBuf, Error> { + let metadata = directory + .symlink_metadata() + .map_err(|error| io_error("inspect asset directory", error))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::UnsafeAssetDestination); + } + directory + .canonicalize() + .map_err(|error| io_error("resolve asset directory", error)) +} + +fn reject_symlink(path: &Path) -> Result<(), Error> { + match path.symlink_metadata() { + Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::UnsafeAssetDestination), + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error("inspect asset destination", error)), + } +} + +fn open_lock(path: &Path) -> Result<File, Error> { + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|error| io_error("open asset lock", error)) +} + +#[cfg(unix)] +fn sync_directory(directory: &Path) -> Result<(), Error> { + File::open(directory) + .and_then(|file| file.sync_all()) + .map_err(|error| io_error("sync asset directory", error)) +} + +#[cfg(not(unix))] +fn sync_directory(_directory: &Path) -> Result<(), Error> { + Ok(()) +} + +struct BoundedWriter<'a> { + destination: &'a mut File, + maximum: u64, + observed: u64, + overflowed: bool, +} + +impl<'a> BoundedWriter<'a> { + fn new(destination: &'a mut File, maximum: u64) -> Self { + Self { + destination, + maximum, + observed: 0, + overflowed: false, + } + } +} + +impl Write for BoundedWriter<'_> { + fn write(&mut self, buffer: &[u8]) -> io::Result<usize> { + let incoming = u64::try_from(buffer.len()).unwrap_or(u64::MAX); + if self.observed.saturating_add(incoming) > self.maximum { + self.overflowed = true; + return Err(io::Error::new( + io::ErrorKind::FileTooLarge, + "asset exceeds declared size", + )); + } + let written = self.destination.write(buffer)?; + self.observed = self + .observed + .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.destination.flush() + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + + use sha2::{Digest, Sha256}; + use tempfile::tempdir; + + use super::{FetchFailurePhase, Fetcher, acquire}; + use crate::asset::inspect; + use crate::{AssetSpec, AssetStatus, Error}; + + struct BytesFetcher(Vec<u8>); + + impl Fetcher for BytesFetcher { + fn fetch(&self, _source: &str, destination: &mut dyn Write) -> Result<(), Error> { + destination.write_all(&self.0).map_err(|_| Error::Fetch { + phase: FetchFailurePhase::Read, + }) + } + } + + struct InterruptedFetcher; + + impl Fetcher for InterruptedFetcher { + fn fetch(&self, _source: &str, destination: &mut dyn Write) -> Result<(), Error> { + destination + .write_all(b"partial") + .map_err(|_| Error::Fetch { + phase: FetchFailurePhase::Read, + })?; + Err(Error::Fetch { + phase: FetchFailurePhase::Cancelled, + }) + } + } + + fn spec(bytes: &[u8]) -> AssetSpec { + AssetSpec::new( + "test-v1", + "geonames-test.db", + "https://assets.example/geonames-test.db", + "assets.example", + u64::try_from(bytes.len()).expect("fixture length"), + Sha256::digest(bytes).into(), + ) + .expect("asset spec") + } + + #[test] + fn missing_and_successful_acquisition_are_explicit() { + let directory = tempdir().expect("tempdir"); + let bytes = b"verified geonames fixture"; + let spec = spec(bytes); + let path = directory.path().join(spec.file_name()); + assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Missing)); + assert_eq!( + acquire(directory.path(), &spec, &BytesFetcher(bytes.to_vec())), + Ok(AssetStatus::Available) + ); + assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Available)); + assert_eq!(fs::read(path).expect("read installed asset"), bytes); + } + + #[test] + fn interrupted_acquisition_leaves_no_destination_or_staging_file() { + let directory = tempdir().expect("tempdir"); + let spec = spec(b"complete bytes"); + let error = acquire(directory.path(), &spec, &InterruptedFetcher) + .expect_err("interrupted fetch must fail"); + assert!(matches!( + error, + Error::Fetch { + phase: FetchFailurePhase::Cancelled + } + )); + assert!(!directory.path().join(spec.file_name()).exists()); + let entries = fs::read_dir(directory.path()) + .expect("read directory") + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .collect::<Vec<_>>(); + assert_eq!( + entries, + vec![std::ffi::OsString::from(format!( + ".{}.lock", + spec.file_name() + ))] + ); + } + + #[test] + fn oversized_and_hash_mismatched_streams_never_replace_existing_asset() { + let directory = tempdir().expect("tempdir"); + let expected = b"expected"; + let spec = spec(expected); + let path = directory.path().join(spec.file_name()); + fs::write(&path, b"old").expect("old destination"); + + assert!(matches!( + acquire( + directory.path(), + &spec, + &BytesFetcher(b"expected-extra".to_vec()) + ), + Err(Error::AssetSizeMismatch { .. }) + )); + assert_eq!(fs::read(&path).expect("preserved old bytes"), b"old"); + + assert_eq!( + acquire(directory.path(), &spec, &BytesFetcher(b"notright".to_vec())), + Err(Error::AssetHashMismatch) + ); + assert_eq!(fs::read(path).expect("preserved old bytes"), b"old"); + } + + #[cfg(unix)] + #[test] + fn symlink_directories_and_destinations_are_rejected() { + use std::os::unix::fs::symlink; + + let root = tempdir().expect("tempdir"); + let real = root.path().join("real"); + fs::create_dir(&real).expect("real directory"); + let linked = root.path().join("linked"); + symlink(&real, &linked).expect("directory symlink"); + let spec = spec(b"asset"); + assert_eq!( + acquire(&linked, &spec, &BytesFetcher(b"asset".to_vec())), + Err(Error::UnsafeAssetDestination) + ); + + let target = real.join(spec.file_name()); + let outside = root.path().join("outside"); + fs::write(&outside, b"outside").expect("outside file"); + symlink(&outside, &target).expect("destination symlink"); + assert_eq!( + acquire(&real, &spec, &BytesFetcher(b"asset".to_vec())), + Err(Error::UnsafeAssetDestination) + ); + } +} diff --git a/crates/geonames/src/error.rs b/crates/geonames/src/error.rs @@ -1,5 +1,7 @@ use std::fmt; +use crate::download::FetchFailurePhase; + /// Failure returned by GeoNames configuration and lookup operations. #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] @@ -8,10 +10,37 @@ pub enum Error { InvalidAssetVersion, /// An asset file name was not one safe, relative path component. InvalidAssetFileName, - /// An asset source was empty or contained surrounding whitespace. + /// An asset source or authority was empty or contained whitespace. InvalidAssetSource, + /// An asset source did not use HTTPS with the declared authority. + UntrustedAssetSource, /// An asset declared a zero byte size. InvalidAssetByteSize, + /// The host-selected destination could not be used safely. + UnsafeAssetDestination, + /// Another acquisition currently owns the destination lock. + AssetDestinationBusy, + /// A filesystem operation failed. + Io { + /// Stable operation label without a host path. + operation: &'static str, + /// Portable I/O failure category. + kind: std::io::ErrorKind, + }, + /// The injected fetcher failed before producing a complete stream. + Fetch { + /// Stable acquisition phase. + phase: FetchFailurePhase, + }, + /// The acquired or inspected asset had the wrong length. + AssetSizeMismatch { + /// Declared asset length. + expected: u64, + /// Observed length, capped at `expected + 1` during acquisition. + actual: u64, + }, + /// The acquired or inspected asset had the wrong digest. + AssetHashMismatch, /// A coordinate was non-finite or outside its geographic bounds. InvalidPoint, /// A query string was empty or contained surrounding whitespace. @@ -24,18 +53,46 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::InvalidAssetVersion => "asset version must be non-empty and normalized", + match self { + Self::InvalidAssetVersion => { + formatter.write_str("asset version must be non-empty and normalized") + } Self::InvalidAssetFileName => { - "asset file name must be one safe relative path component" - } - Self::InvalidAssetSource => "asset source must be non-empty and normalized", - Self::InvalidAssetByteSize => "asset byte size must be greater than zero", - Self::InvalidPoint => "point must contain finite, in-range coordinates", - Self::InvalidQueryText => "query text must be non-empty and normalized", - Self::InvalidQueryLimit => "query limit must be between 1 and 100", - Self::QueryOptionNotApplicable => "query option is not applicable to this query kind", - }) + formatter.write_str("asset file name must be one safe relative path component") + } + Self::InvalidAssetSource => { + formatter.write_str("asset source and authority must be non-empty and normalized") + } + Self::UntrustedAssetSource => { + formatter.write_str("asset source must use HTTPS and the declared authority") + } + Self::InvalidAssetByteSize => { + formatter.write_str("asset byte size must be greater than zero") + } + Self::UnsafeAssetDestination => formatter.write_str("asset destination is unsafe"), + Self::AssetDestinationBusy => { + formatter.write_str("asset destination is already being acquired") + } + Self::Io { operation, kind } => write!(formatter, "{operation} failed: {kind}"), + Self::Fetch { phase } => write!(formatter, "asset fetch failed during {phase}"), + Self::AssetSizeMismatch { expected, actual } => write!( + formatter, + "asset size mismatch: expected {expected} bytes, observed {actual}" + ), + Self::AssetHashMismatch => { + formatter.write_str("asset SHA-256 does not match its specification") + } + Self::InvalidPoint => { + formatter.write_str("point must contain finite, in-range coordinates") + } + Self::InvalidQueryText => { + formatter.write_str("query text must be non-empty and normalized") + } + Self::InvalidQueryLimit => formatter.write_str("query limit must be between 1 and 100"), + Self::QueryOptionNotApplicable => { + formatter.write_str("query option is not applicable to this query kind") + } + } } }