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:
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")
+ }
+ }
}
}