lib

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

asset.rs (12506B)


      1 //! Host-supplied GeoNames asset identity and passive status.
      2 
      3 use std::fs::File;
      4 use std::io::Read;
      5 use std::path::Path;
      6 
      7 use sha2::{Digest, Sha256};
      8 use url::Url;
      9 
     10 use crate::Error;
     11 
     12 /// Version of the first governed Radroots GeoNames asset.
     13 pub const OFFICIAL_ASSET_VERSION: &str = "1.0";
     14 /// File name of the first governed Radroots GeoNames asset.
     15 pub const OFFICIAL_ASSET_FILE_NAME: &str = "geonames-1.0.db";
     16 /// HTTPS source of the first governed Radroots GeoNames asset.
     17 pub const OFFICIAL_ASSET_SOURCE: &str = "https://assets.radroots.io/data/geonames/geonames-1.0.db";
     18 /// Exact byte size of the first governed Radroots GeoNames asset.
     19 pub const OFFICIAL_ASSET_BYTE_SIZE: u64 = 12_951_552;
     20 /// Exact SHA-256 of the first governed Radroots GeoNames asset.
     21 pub const OFFICIAL_ASSET_SHA256: [u8; 32] = [
     22     0x6c, 0xa5, 0xf1, 0xa3, 0x24, 0xde, 0x02, 0x92, 0x2d, 0x40, 0xb1, 0xff, 0x33, 0xee, 0xdf, 0x3a,
     23     0x5a, 0x13, 0x3c, 0x97, 0x8d, 0xe9, 0x21, 0xee, 0xe5, 0x13, 0x0a, 0x0c, 0x78, 0x76, 0x07, 0x9c,
     24 ];
     25 
     26 /// Returns the immutable specification for the governed Radroots asset.
     27 #[must_use]
     28 pub fn official_asset_spec() -> AssetSpec {
     29     AssetSpec {
     30         version: OFFICIAL_ASSET_VERSION.to_owned(),
     31         file_name: OFFICIAL_ASSET_FILE_NAME.to_owned(),
     32         source: OFFICIAL_ASSET_SOURCE.to_owned(),
     33         allowed_host: "assets.radroots.io".to_owned(),
     34         byte_size: OFFICIAL_ASSET_BYTE_SIZE,
     35         sha256: OFFICIAL_ASSET_SHA256,
     36     }
     37 }
     38 
     39 /// The expected identity of one immutable GeoNames database asset.
     40 #[derive(Clone, Debug, PartialEq, Eq)]
     41 pub struct AssetSpec {
     42     version: String,
     43     file_name: String,
     44     source: String,
     45     allowed_host: String,
     46     byte_size: u64,
     47     sha256: [u8; 32],
     48 }
     49 
     50 impl AssetSpec {
     51     /// Creates an explicit asset specification without reading or downloading it.
     52     pub fn new(
     53         version: impl Into<String>,
     54         file_name: impl Into<String>,
     55         source: impl Into<String>,
     56         allowed_host: impl Into<String>,
     57         byte_size: u64,
     58         sha256: [u8; 32],
     59     ) -> Result<Self, Error> {
     60         let version = version.into();
     61         if !is_normalized_non_empty(&version) {
     62             return Err(Error::InvalidAssetVersion);
     63         }
     64 
     65         let file_name = file_name.into();
     66         if !is_safe_file_name(&file_name) {
     67             return Err(Error::InvalidAssetFileName);
     68         }
     69 
     70         let source = source.into();
     71         let allowed_host = allowed_host.into();
     72         if !is_normalized_non_empty(&source) || !is_normalized_non_empty(&allowed_host) {
     73             return Err(Error::InvalidAssetSource);
     74         }
     75         validate_source(&source, &allowed_host)?;
     76         if byte_size == 0 {
     77             return Err(Error::InvalidAssetByteSize);
     78         }
     79 
     80         Ok(Self {
     81             version,
     82             file_name,
     83             source,
     84             allowed_host,
     85             byte_size,
     86             sha256,
     87         })
     88     }
     89 
     90     /// Returns the provider asset version.
     91     #[must_use]
     92     pub fn version(&self) -> &str {
     93         &self.version
     94     }
     95 
     96     /// Returns the expected destination file name.
     97     #[must_use]
     98     pub fn file_name(&self) -> &str {
     99         &self.file_name
    100     }
    101 
    102     /// Returns the explicit HTTPS source.
    103     #[must_use]
    104     pub fn source(&self) -> &str {
    105         &self.source
    106     }
    107 
    108     /// Returns the exact HTTPS host allowed for acquisition.
    109     #[must_use]
    110     pub fn allowed_host(&self) -> &str {
    111         &self.allowed_host
    112     }
    113 
    114     /// Returns the exact expected byte size.
    115     #[must_use]
    116     pub const fn byte_size(&self) -> u64 {
    117         self.byte_size
    118     }
    119 
    120     /// Returns the expected SHA-256 digest bytes.
    121     #[must_use]
    122     pub const fn sha256(&self) -> &[u8; 32] {
    123         &self.sha256
    124     }
    125 }
    126 
    127 /// Passive state of an explicitly inspected asset.
    128 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    129 #[non_exhaustive]
    130 pub enum AssetStatus {
    131     /// No filesystem entry exists at the inspected path.
    132     Missing,
    133     /// The entry matches the complete [`AssetSpec`].
    134     Available,
    135     /// The entry exists but does not match the specification.
    136     Invalid,
    137 }
    138 
    139 /// Inspects an explicit path without creating, repairing, or downloading it.
    140 pub fn inspect(path: impl AsRef<Path>, spec: &AssetSpec) -> Result<AssetStatus, Error> {
    141     let path = path.as_ref();
    142     let metadata = match path.symlink_metadata() {
    143         Ok(metadata) => metadata,
    144         Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
    145             return Ok(AssetStatus::Missing);
    146         }
    147         Err(error) => return Err(io_error("inspect asset metadata", error)),
    148     };
    149     if metadata.file_type().is_symlink() || !metadata.is_file() {
    150         return Err(Error::UnsafeAssetDestination);
    151     }
    152     match verify_file(path, spec) {
    153         Ok(()) => Ok(AssetStatus::Available),
    154         Err(Error::AssetSizeMismatch { .. } | Error::AssetHashMismatch) => Ok(AssetStatus::Invalid),
    155         Err(error) => Err(error),
    156     }
    157 }
    158 
    159 pub(crate) fn verify_file(path: &Path, spec: &AssetSpec) -> Result<(), Error> {
    160     let mut file = File::open(path).map_err(|error| io_error("open asset", error))?;
    161     let mut digest = Sha256::new();
    162     let mut buffer = [0_u8; 64 * 1024];
    163     let mut observed = 0_u64;
    164     loop {
    165         let read = file
    166             .read(&mut buffer)
    167             .map_err(|error| io_error("read asset", error))?;
    168         if read == 0 {
    169             break;
    170         }
    171         observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
    172         if observed > spec.byte_size {
    173             return Err(Error::AssetSizeMismatch {
    174                 expected: spec.byte_size,
    175                 actual: observed,
    176             });
    177         }
    178         digest.update(&buffer[..read]);
    179     }
    180     if observed != spec.byte_size {
    181         return Err(Error::AssetSizeMismatch {
    182             expected: spec.byte_size,
    183             actual: observed,
    184         });
    185     }
    186     let actual: [u8; 32] = digest.finalize().into();
    187     if actual != spec.sha256 {
    188         return Err(Error::AssetHashMismatch);
    189     }
    190     Ok(())
    191 }
    192 
    193 pub(crate) fn io_error(operation: &'static str, error: std::io::Error) -> Error {
    194     Error::Io {
    195         operation,
    196         kind: error.kind(),
    197     }
    198 }
    199 
    200 fn is_normalized_non_empty(value: &str) -> bool {
    201     !value.is_empty() && value.trim() == value
    202 }
    203 
    204 fn is_safe_file_name(value: &str) -> bool {
    205     is_normalized_non_empty(value)
    206         && value != "."
    207         && value != ".."
    208         && !value.contains(['/', '\\', ':', '\0'])
    209 }
    210 
    211 fn validate_source(source: &str, allowed_host: &str) -> Result<(), Error> {
    212     let parsed = Url::parse(source).map_err(|_| Error::UntrustedAssetSource)?;
    213     let trusted = parsed.scheme() == "https"
    214         && parsed.host_str() == Some(allowed_host)
    215         && parsed.port_or_known_default() == Some(443)
    216         && parsed.username().is_empty()
    217         && parsed.password().is_none()
    218         && parsed.query().is_none()
    219         && parsed.fragment().is_none();
    220     if !trusted {
    221         return Err(Error::UntrustedAssetSource);
    222     }
    223     Ok(())
    224 }
    225 
    226 #[cfg(test)]
    227 mod tests {
    228     use std::fs;
    229 
    230     use sha2::{Digest, Sha256};
    231     use tempfile::tempdir;
    232 
    233     use super::{
    234         AssetSpec, AssetStatus, OFFICIAL_ASSET_SHA256, inspect, io_error, official_asset_spec,
    235     };
    236     use crate::Error;
    237 
    238     fn spec() -> AssetSpec {
    239         AssetSpec::new(
    240             "2026-08",
    241             "geonames-2026-08.db",
    242             "https://assets.example/geonames-2026-08.db",
    243             "assets.example",
    244             42,
    245             [7; 32],
    246         )
    247         .expect("valid asset specification")
    248     }
    249 
    250     #[test]
    251     fn asset_spec_preserves_explicit_identity() {
    252         let spec = spec();
    253         assert_eq!(spec.version(), "2026-08");
    254         assert_eq!(spec.file_name(), "geonames-2026-08.db");
    255         assert_eq!(spec.source(), "https://assets.example/geonames-2026-08.db");
    256         assert_eq!(spec.allowed_host(), "assets.example");
    257         assert_eq!(spec.byte_size(), 42);
    258         assert_eq!(spec.sha256(), &[7; 32]);
    259     }
    260 
    261     #[test]
    262     fn official_asset_identity_is_byte_pinned_without_fixture_features() {
    263         let spec = official_asset_spec();
    264         assert_eq!(spec.version(), "1.0");
    265         assert_eq!(spec.file_name(), "geonames-1.0.db");
    266         assert_eq!(spec.allowed_host(), "assets.radroots.io");
    267         assert_eq!(spec.byte_size(), 12_951_552);
    268         assert_eq!(spec.sha256(), &OFFICIAL_ASSET_SHA256);
    269     }
    270 
    271     #[test]
    272     fn asset_spec_rejects_ambient_unsafe_or_untrusted_values() {
    273         let valid_source = "https://assets.example/a";
    274         assert_eq!(
    275             AssetSpec::new(" ", "asset.db", valid_source, "assets.example", 1, [0; 32]),
    276             Err(Error::InvalidAssetVersion)
    277         );
    278         assert_eq!(
    279             AssetSpec::new(
    280                 "v1",
    281                 "../asset.db",
    282                 valid_source,
    283                 "assets.example",
    284                 1,
    285                 [0; 32]
    286             ),
    287             Err(Error::InvalidAssetFileName)
    288         );
    289         assert_eq!(
    290             AssetSpec::new("v1", "asset.db", " source", "assets.example", 1, [0; 32]),
    291             Err(Error::InvalidAssetSource)
    292         );
    293         assert_eq!(
    294             AssetSpec::new("v1", "asset.db", valid_source, "assets.example", 0, [0; 32]),
    295             Err(Error::InvalidAssetByteSize)
    296         );
    297         assert_eq!(
    298             AssetSpec::new(
    299                 "v1",
    300                 "asset.db",
    301                 "http://assets.example/a",
    302                 "assets.example",
    303                 1,
    304                 [0; 32]
    305             ),
    306             Err(Error::UntrustedAssetSource)
    307         );
    308         assert_eq!(
    309             AssetSpec::new(
    310                 "v1",
    311                 "asset.db",
    312                 "https://other.example/a",
    313                 "assets.example",
    314                 1,
    315                 [0; 32]
    316             ),
    317             Err(Error::UntrustedAssetSource)
    318         );
    319     }
    320 
    321     #[test]
    322     fn asset_status_is_passive_and_exhaustive_for_v1() {
    323         assert_ne!(AssetStatus::Missing, AssetStatus::Available);
    324         assert_ne!(AssetStatus::Available, AssetStatus::Invalid);
    325     }
    326 
    327     #[test]
    328     fn asset_validation_rejects_every_unsafe_name_and_url_shape() {
    329         let source = "https://assets.example/a";
    330         for file_name in ["", ".", "..", "a/b", "a\\b", "a:b", "a\0b"] {
    331             assert_eq!(
    332                 AssetSpec::new("v1", file_name, source, "assets.example", 1, [0; 32]),
    333                 Err(Error::InvalidAssetFileName)
    334             );
    335         }
    336         for untrusted in [
    337             "not a url",
    338             "https://user@assets.example/a",
    339             "https://user:pass@assets.example/a",
    340             "https://:pass@assets.example/a",
    341             "https://assets.example:444/a",
    342             "https://assets.example/a?token=secret",
    343             "https://assets.example/a#fragment",
    344         ] {
    345             assert_eq!(
    346                 AssetSpec::new("v1", "asset.db", untrusted, "assets.example", 1, [0; 32]),
    347                 Err(Error::UntrustedAssetSource)
    348             );
    349         }
    350         assert_eq!(
    351             AssetSpec::new("v1", "asset.db", source, " ", 1, [0; 32]),
    352             Err(Error::InvalidAssetSource)
    353         );
    354     }
    355 
    356     #[test]
    357     fn passive_inspection_distinguishes_regular_invalid_and_unsafe_entries() {
    358         let directory = tempdir().expect("tempdir");
    359         let bytes = b"asset bytes";
    360         let spec = AssetSpec::new(
    361             "v1",
    362             "asset.db",
    363             "https://assets.example/a",
    364             "assets.example",
    365             u64::try_from(bytes.len()).expect("length"),
    366             Sha256::digest(bytes).into(),
    367         )
    368         .expect("spec");
    369         let path = directory.path().join("asset.db");
    370         fs::write(&path, b"short").expect("short asset");
    371         assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Invalid));
    372         fs::write(&path, b"wrong bytes").expect("wrong hash asset");
    373         assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Invalid));
    374         fs::write(&path, bytes).expect("valid asset");
    375         assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Available));
    376         assert_eq!(
    377             inspect(directory.path(), &spec),
    378             Err(Error::UnsafeAssetDestination)
    379         );
    380         assert_eq!(
    381             io_error(
    382                 "read asset",
    383                 std::io::Error::from(std::io::ErrorKind::BrokenPipe)
    384             ),
    385             Error::Io {
    386                 operation: "read asset",
    387                 kind: std::io::ErrorKind::BrokenPipe,
    388             }
    389         );
    390     }
    391 }