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 }