document.rs (22913B)
1 //! Bounded loading and exact versioned TOML document admission. 2 3 use core::fmt; 4 use serde::de::DeserializeOwned; 5 use std::error::Error; 6 use std::fs::File; 7 use std::io::{self, Read}; 8 use std::path::{Path, PathBuf}; 9 10 /// Hard upper bound for one complete service configuration document. 11 pub const CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize = 1024 * 1024; 12 13 /// Hard upper bound for a versioned configuration schema identifier. 14 pub const CONFIG_SCHEMA_ID_MAX_UTF8_BYTES: usize = 128; 15 16 /// Validated schema identity and exact version expected from one document. 17 #[derive(Clone, PartialEq, Eq)] 18 pub struct ConfigDocumentExpectation { 19 schema: Box<str>, 20 schema_version: u32, 21 } 22 23 impl ConfigDocumentExpectation { 24 /// Creates an exact expected document identity. 25 pub fn new( 26 schema: impl AsRef<str>, 27 schema_version: u32, 28 ) -> Result<Self, ConfigDocumentExpectationError> { 29 let schema = schema.as_ref(); 30 if !valid_schema_id(schema) { 31 return Err(ConfigDocumentExpectationError::InvalidSchema); 32 } 33 if schema_version == 0 { 34 return Err(ConfigDocumentExpectationError::InvalidSchemaVersion); 35 } 36 Ok(Self { 37 schema: schema.to_owned().into_boxed_str(), 38 schema_version, 39 }) 40 } 41 42 /// Returns the exact schema identifier. 43 #[must_use] 44 pub fn schema(&self) -> &str { 45 &self.schema 46 } 47 48 /// Returns the exact schema version. 49 #[must_use] 50 pub const fn schema_version(&self) -> u32 { 51 self.schema_version 52 } 53 } 54 55 impl fmt::Debug for ConfigDocumentExpectation { 56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 57 formatter 58 .debug_struct("ConfigDocumentExpectation") 59 .field("schema", &self.schema) 60 .field("schema_version", &self.schema_version) 61 .finish() 62 } 63 } 64 65 /// Invalid caller-provided schema expectation. 66 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 67 pub enum ConfigDocumentExpectationError { 68 InvalidSchema, 69 InvalidSchemaVersion, 70 } 71 72 impl fmt::Display for ConfigDocumentExpectationError { 73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 74 formatter.write_str("configuration document expectation is invalid") 75 } 76 } 77 78 impl Error for ConfigDocumentExpectationError {} 79 80 /// Stable classification for a configuration document failure. 81 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 82 pub enum ConfigDocumentErrorKind { 83 NotFound, 84 Read, 85 TooLarge, 86 InvalidUtf8, 87 MalformedToml, 88 MissingSchema, 89 InvalidSchema, 90 SchemaMismatch, 91 MissingSchemaVersion, 92 InvalidSchemaVersion, 93 UnsupportedSchemaVersion, 94 TypedDocument, 95 } 96 97 impl ConfigDocumentErrorKind { 98 const fn message(self) -> &'static str { 99 match self { 100 Self::NotFound => "configuration document was not found", 101 Self::Read => "configuration document could not be read", 102 Self::TooLarge => "configuration document exceeds its size limit", 103 Self::InvalidUtf8 => "configuration document is not valid UTF-8", 104 Self::MalformedToml => "configuration document is not valid TOML", 105 Self::MissingSchema => "configuration document schema is missing", 106 Self::InvalidSchema => "configuration document schema is invalid", 107 Self::SchemaMismatch => "configuration document schema is unsupported", 108 Self::MissingSchemaVersion => "configuration document schema version is missing", 109 Self::InvalidSchemaVersion => "configuration document schema version is invalid", 110 Self::UnsupportedSchemaVersion => { 111 "configuration document schema version is unsupported" 112 } 113 Self::TypedDocument => "configuration document fields are invalid", 114 } 115 } 116 } 117 118 /// One-based location inside the selected source document. 119 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 120 pub struct ConfigDocumentLocation { 121 line: u32, 122 column: u32, 123 } 124 125 impl ConfigDocumentLocation { 126 /// Returns the one-based line. 127 #[must_use] 128 pub const fn line(self) -> u32 { 129 self.line 130 } 131 132 /// Returns the one-based UTF-8 character column. 133 #[must_use] 134 pub const fn column(self) -> u32 { 135 self.column 136 } 137 } 138 139 /// Source-located failure with a redacted ordinary representation. 140 pub struct ConfigDocumentError { 141 kind: ConfigDocumentErrorKind, 142 source_path: PathBuf, 143 location: Option<ConfigDocumentLocation>, 144 io_kind: Option<io::ErrorKind>, 145 } 146 147 impl ConfigDocumentError { 148 /// Returns the stable failure classification. 149 #[must_use] 150 pub const fn kind(&self) -> ConfigDocumentErrorKind { 151 self.kind 152 } 153 154 /// Returns the selected source path for trusted local diagnostics. 155 #[must_use] 156 pub fn trusted_source_path(&self) -> &Path { 157 &self.source_path 158 } 159 160 /// Returns a parser-provided source location when one is available. 161 #[must_use] 162 pub const fn location(&self) -> Option<ConfigDocumentLocation> { 163 self.location 164 } 165 166 /// Returns only the safe I/O classification when the failure came from I/O. 167 #[must_use] 168 pub const fn io_kind(&self) -> Option<io::ErrorKind> { 169 self.io_kind 170 } 171 172 fn without_source(kind: ConfigDocumentErrorKind, source_path: &Path) -> Self { 173 Self { 174 kind, 175 source_path: source_path.to_path_buf(), 176 location: None, 177 io_kind: None, 178 } 179 } 180 181 fn with_io_kind( 182 kind: ConfigDocumentErrorKind, 183 source_path: &Path, 184 io_kind: io::ErrorKind, 185 ) -> Self { 186 Self { 187 kind, 188 source_path: source_path.to_path_buf(), 189 location: None, 190 io_kind: Some(io_kind), 191 } 192 } 193 194 fn with_toml_source( 195 kind: ConfigDocumentErrorKind, 196 source_path: &Path, 197 document: &str, 198 source: &toml::de::Error, 199 ) -> Self { 200 let location = source 201 .span() 202 .and_then(|span| location_at(document, span.start)); 203 Self { 204 kind, 205 source_path: source_path.to_path_buf(), 206 location, 207 io_kind: None, 208 } 209 } 210 } 211 212 impl fmt::Debug for ConfigDocumentError { 213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 214 formatter 215 .debug_struct("ConfigDocumentError") 216 .field("kind", &self.kind) 217 .field("source_path", &"[redacted]") 218 .field("location", &self.location) 219 .field("io_kind", &self.io_kind) 220 .finish() 221 } 222 } 223 224 impl fmt::Display for ConfigDocumentError { 225 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 226 formatter.write_str(self.kind.message()) 227 } 228 } 229 230 impl Error for ConfigDocumentError {} 231 232 /// Reads and strictly deserializes one selected, exact-version TOML document. 233 pub fn load_config_document<T>( 234 source_path: &Path, 235 expectation: &ConfigDocumentExpectation, 236 ) -> Result<T, ConfigDocumentError> 237 where 238 T: DeserializeOwned, 239 { 240 load_with_source(&FileDocumentSource, source_path, expectation) 241 } 242 243 trait DocumentSource { 244 type Reader: Read; 245 246 fn open_selected(&self, source_path: &Path) -> io::Result<Self::Reader>; 247 } 248 249 struct FileDocumentSource; 250 251 impl DocumentSource for FileDocumentSource { 252 type Reader = File; 253 254 fn open_selected(&self, source_path: &Path) -> io::Result<Self::Reader> { 255 File::open(source_path) 256 } 257 } 258 259 fn load_with_source<T, S>( 260 source: &S, 261 source_path: &Path, 262 expectation: &ConfigDocumentExpectation, 263 ) -> Result<T, ConfigDocumentError> 264 where 265 T: DeserializeOwned, 266 S: DocumentSource, 267 { 268 let reader = source.open_selected(source_path).map_err(|error| { 269 let kind = if error.kind() == io::ErrorKind::NotFound { 270 ConfigDocumentErrorKind::NotFound 271 } else { 272 ConfigDocumentErrorKind::Read 273 }; 274 ConfigDocumentError::with_io_kind(kind, source_path, error.kind()) 275 })?; 276 let bytes = read_bounded(reader, source_path)?; 277 let document = String::from_utf8(bytes).map_err(|_| { 278 ConfigDocumentError::without_source(ConfigDocumentErrorKind::InvalidUtf8, source_path) 279 })?; 280 let header = document.parse::<toml::Table>().map_err(|error| { 281 ConfigDocumentError::with_toml_source( 282 ConfigDocumentErrorKind::MalformedToml, 283 source_path, 284 &document, 285 &error, 286 ) 287 })?; 288 validate_header(&header, source_path, expectation)?; 289 toml::from_str(&document).map_err(|error| { 290 ConfigDocumentError::with_toml_source( 291 ConfigDocumentErrorKind::TypedDocument, 292 source_path, 293 &document, 294 &error, 295 ) 296 }) 297 } 298 299 fn read_bounded(reader: impl Read, source_path: &Path) -> Result<Vec<u8>, ConfigDocumentError> { 300 let allocation = CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1; 301 let mut bytes = Vec::with_capacity(allocation); 302 reader 303 .take(allocation as u64) 304 .read_to_end(&mut bytes) 305 .map_err(|error| { 306 ConfigDocumentError::with_io_kind( 307 ConfigDocumentErrorKind::Read, 308 source_path, 309 error.kind(), 310 ) 311 })?; 312 if bytes.len() > CONFIG_DOCUMENT_MAX_UTF8_BYTES { 313 return Err(ConfigDocumentError::without_source( 314 ConfigDocumentErrorKind::TooLarge, 315 source_path, 316 )); 317 } 318 Ok(bytes) 319 } 320 321 fn validate_header( 322 header: &toml::Table, 323 source_path: &Path, 324 expectation: &ConfigDocumentExpectation, 325 ) -> Result<(), ConfigDocumentError> { 326 let schema = header 327 .get("schema") 328 .ok_or_else(|| { 329 ConfigDocumentError::without_source(ConfigDocumentErrorKind::MissingSchema, source_path) 330 })? 331 .as_str() 332 .ok_or_else(|| { 333 ConfigDocumentError::without_source(ConfigDocumentErrorKind::InvalidSchema, source_path) 334 })?; 335 if !valid_schema_id(schema) { 336 return Err(ConfigDocumentError::without_source( 337 ConfigDocumentErrorKind::InvalidSchema, 338 source_path, 339 )); 340 } 341 if schema != expectation.schema() { 342 return Err(ConfigDocumentError::without_source( 343 ConfigDocumentErrorKind::SchemaMismatch, 344 source_path, 345 )); 346 } 347 348 let version = header 349 .get("schema_version") 350 .ok_or_else(|| { 351 ConfigDocumentError::without_source( 352 ConfigDocumentErrorKind::MissingSchemaVersion, 353 source_path, 354 ) 355 })? 356 .as_integer() 357 .ok_or_else(|| { 358 ConfigDocumentError::without_source( 359 ConfigDocumentErrorKind::InvalidSchemaVersion, 360 source_path, 361 ) 362 })?; 363 let version = u32::try_from(version).map_err(|_| { 364 ConfigDocumentError::without_source( 365 ConfigDocumentErrorKind::InvalidSchemaVersion, 366 source_path, 367 ) 368 })?; 369 if version == 0 { 370 return Err(ConfigDocumentError::without_source( 371 ConfigDocumentErrorKind::InvalidSchemaVersion, 372 source_path, 373 )); 374 } 375 if version != expectation.schema_version() { 376 return Err(ConfigDocumentError::without_source( 377 ConfigDocumentErrorKind::UnsupportedSchemaVersion, 378 source_path, 379 )); 380 } 381 Ok(()) 382 } 383 384 fn valid_schema_id(value: &str) -> bool { 385 let mut bytes = value.bytes(); 386 value.len() <= CONFIG_SCHEMA_ID_MAX_UTF8_BYTES 387 && bytes 388 .next() 389 .is_some_and(|byte| byte.is_ascii_alphanumeric()) 390 && bytes 391 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) 392 } 393 394 fn location_at(document: &str, byte_offset: usize) -> Option<ConfigDocumentLocation> { 395 let prefix = document.get(..byte_offset)?; 396 let line = u32::try_from(prefix.bytes().filter(|byte| *byte == b'\n').count()) 397 .ok()? 398 .checked_add(1)?; 399 let column = u32::try_from( 400 prefix 401 .rsplit_once('\n') 402 .map_or(prefix, |(_, tail)| tail) 403 .chars() 404 .count(), 405 ) 406 .ok()? 407 .checked_add(1)?; 408 Some(ConfigDocumentLocation { line, column }) 409 } 410 411 #[cfg(test)] 412 mod tests { 413 use std::cell::RefCell; 414 use std::io::Cursor; 415 416 use serde::Deserialize; 417 use tempfile::tempdir; 418 419 use super::*; 420 421 const SOURCE_PATH: &str = "/private/config/secret-instance.toml"; 422 423 #[derive(Debug, Deserialize, PartialEq, Eq)] 424 #[serde(deny_unknown_fields)] 425 struct StrictDocument { 426 schema: String, 427 schema_version: u32, 428 service: StrictService, 429 } 430 431 #[derive(Debug, Deserialize, PartialEq, Eq)] 432 #[serde(deny_unknown_fields)] 433 struct StrictService { 434 instance: String, 435 } 436 437 #[derive(Debug, Deserialize, PartialEq, Eq)] 438 struct PermissiveDocument { 439 schema: String, 440 schema_version: u32, 441 } 442 443 fn expectation() -> ConfigDocumentExpectation { 444 ConfigDocumentExpectation::new("radroots.example.config", 1).unwrap() 445 } 446 447 fn valid_document() -> &'static str { 448 concat!( 449 "schema = \"radroots.example.config\"\n", 450 "schema_version = 1\n", 451 "[service]\n", 452 "instance = \"default\"\n", 453 ) 454 } 455 456 #[test] 457 fn exact_document_is_bounded_and_strictly_deserialized() { 458 let directory = tempdir().unwrap(); 459 let path = directory.path().join("config.toml"); 460 std::fs::write(&path, valid_document()).unwrap(); 461 462 let loaded = load_config_document::<StrictDocument>(&path, &expectation()).unwrap(); 463 assert_eq!(loaded.schema, "radroots.example.config"); 464 assert_eq!(loaded.schema_version, 1); 465 assert_eq!(loaded.service.instance, "default"); 466 467 let exact_padding = CONFIG_DOCUMENT_MAX_UTF8_BYTES - valid_document().len() - 2; 468 let exact = format!("{}#{}\n", valid_document(), "x".repeat(exact_padding)); 469 assert_eq!(exact.len(), CONFIG_DOCUMENT_MAX_UTF8_BYTES); 470 std::fs::write(&path, exact).unwrap(); 471 assert!(load_config_document::<StrictDocument>(&path, &expectation()).is_ok()); 472 473 std::fs::write(&path, vec![b'x'; CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1]).unwrap(); 474 assert_eq!( 475 load_config_document::<StrictDocument>(&path, &expectation()) 476 .unwrap_err() 477 .kind(), 478 ConfigDocumentErrorKind::TooLarge 479 ); 480 } 481 482 #[test] 483 fn missing_unreadable_and_invalid_utf8_documents_fail_closed() { 484 let directory = tempdir().unwrap(); 485 let missing = directory.path().join("missing.toml"); 486 assert_eq!( 487 load_config_document::<StrictDocument>(&missing, &expectation()) 488 .unwrap_err() 489 .kind(), 490 ConfigDocumentErrorKind::NotFound 491 ); 492 493 let invalid_utf8 = directory.path().join("invalid.toml"); 494 std::fs::write(&invalid_utf8, [0xff, 0xfe]).unwrap(); 495 assert_eq!( 496 load_config_document::<StrictDocument>(&invalid_utf8, &expectation()) 497 .unwrap_err() 498 .kind(), 499 ConfigDocumentErrorKind::InvalidUtf8 500 ); 501 502 struct FailingSource; 503 impl DocumentSource for FailingSource { 504 type Reader = Cursor<Vec<u8>>; 505 506 fn open_selected(&self, _source_path: &Path) -> io::Result<Self::Reader> { 507 Err(io::Error::new(io::ErrorKind::PermissionDenied, "sensitive")) 508 } 509 } 510 let error = load_with_source::<StrictDocument, _>( 511 &FailingSource, 512 Path::new(SOURCE_PATH), 513 &expectation(), 514 ) 515 .unwrap_err(); 516 assert_eq!(error.kind(), ConfigDocumentErrorKind::Read); 517 assert_eq!(error.io_kind(), Some(io::ErrorKind::PermissionDenied)); 518 assert!(error.source().is_none()); 519 } 520 521 #[test] 522 fn malformed_toml_and_typed_errors_retain_safe_locations() { 523 let malformed = RecordingSource::new(b"token = \"secret-value\n".to_vec()); 524 let error = load_with_source::<StrictDocument, _>( 525 &malformed, 526 Path::new(SOURCE_PATH), 527 &expectation(), 528 ) 529 .unwrap_err(); 530 assert_eq!(error.kind(), ConfigDocumentErrorKind::MalformedToml); 531 assert!(error.location().is_some()); 532 assert_public_error_chain_is_safe(&error, &[SOURCE_PATH, "secret-value", "token"]); 533 534 let typed = RecordingSource::new( 535 concat!( 536 "schema = \"radroots.example.config\"\n", 537 "schema_version = 1\n", 538 "credential_secret = \"secret-value\"\n", 539 "[service]\n", 540 "instance = \"default\"\n", 541 ) 542 .as_bytes() 543 .to_vec(), 544 ); 545 let error = 546 load_with_source::<StrictDocument, _>(&typed, Path::new(SOURCE_PATH), &expectation()) 547 .unwrap_err(); 548 assert_eq!(error.kind(), ConfigDocumentErrorKind::TypedDocument); 549 let location = error.location().unwrap(); 550 assert!(location.line() >= 3); 551 assert!(location.column() >= 1); 552 assert_eq!(error.trusted_source_path(), Path::new(SOURCE_PATH)); 553 assert_public_error_chain_is_safe( 554 &error, 555 &[SOURCE_PATH, "credential_secret", "secret-value"], 556 ); 557 assert_eq!( 558 error.to_string(), 559 "configuration document fields are invalid" 560 ); 561 } 562 563 #[test] 564 fn header_identity_is_required_valid_and_checked_before_typed_fields() { 565 for (source, kind) in [ 566 ( 567 "schema_version = 1\n", 568 ConfigDocumentErrorKind::MissingSchema, 569 ), 570 ( 571 "schema = 7\nschema_version = 1\n", 572 ConfigDocumentErrorKind::InvalidSchema, 573 ), 574 ( 575 "schema = \"radroots.other.config\"\nschema_version = 1\nunknown = true\n", 576 ConfigDocumentErrorKind::SchemaMismatch, 577 ), 578 ( 579 "schema = \"radroots.example.config\"\n", 580 ConfigDocumentErrorKind::MissingSchemaVersion, 581 ), 582 ( 583 "schema = \"radroots.example.config\"\nschema_version = \"one\"\n", 584 ConfigDocumentErrorKind::InvalidSchemaVersion, 585 ), 586 ( 587 "schema = \"radroots.example.config\"\nschema_version = 0\n", 588 ConfigDocumentErrorKind::InvalidSchemaVersion, 589 ), 590 ( 591 "schema = \"radroots.example.config\"\nschema_version = 4294967296\n", 592 ConfigDocumentErrorKind::InvalidSchemaVersion, 593 ), 594 ( 595 "schema = \"radroots.example.config\"\nschema_version = 2\n", 596 ConfigDocumentErrorKind::UnsupportedSchemaVersion, 597 ), 598 ] { 599 let source = RecordingSource::new(source.as_bytes().to_vec()); 600 assert_eq!( 601 load_with_source::<StrictDocument, _>( 602 &source, 603 Path::new(SOURCE_PATH), 604 &expectation(), 605 ) 606 .unwrap_err() 607 .kind(), 608 kind 609 ); 610 } 611 } 612 613 #[test] 614 fn unknown_field_policy_is_owned_by_the_exact_typed_document() { 615 let source = RecordingSource::new( 616 concat!( 617 "schema = \"radroots.example.config\"\n", 618 "schema_version = 1\n", 619 "extension = true\n", 620 ) 621 .as_bytes() 622 .to_vec(), 623 ); 624 let permissive = load_with_source::<PermissiveDocument, _>( 625 &source, 626 Path::new(SOURCE_PATH), 627 &expectation(), 628 ) 629 .unwrap(); 630 assert_eq!(permissive.schema, "radroots.example.config"); 631 assert_eq!(permissive.schema_version, 1); 632 } 633 634 #[test] 635 fn expectation_validation_is_exact_and_bounded() { 636 assert_eq!( 637 ConfigDocumentExpectation::new("", 1).unwrap_err(), 638 ConfigDocumentExpectationError::InvalidSchema 639 ); 640 assert_eq!( 641 ConfigDocumentExpectation::new(".radroots.config", 1).unwrap_err(), 642 ConfigDocumentExpectationError::InvalidSchema 643 ); 644 assert_eq!( 645 ConfigDocumentExpectation::new("bad schema", 1).unwrap_err(), 646 ConfigDocumentExpectationError::InvalidSchema 647 ); 648 assert!( 649 ConfigDocumentExpectation::new("a".repeat(CONFIG_SCHEMA_ID_MAX_UTF8_BYTES), 1).is_ok() 650 ); 651 assert_eq!( 652 ConfigDocumentExpectation::new("a".repeat(CONFIG_SCHEMA_ID_MAX_UTF8_BYTES + 1), 1,) 653 .unwrap_err(), 654 ConfigDocumentExpectationError::InvalidSchema 655 ); 656 assert_eq!( 657 ConfigDocumentExpectation::new("a".repeat(4 * 1024 * 1024), 1).unwrap_err(), 658 ConfigDocumentExpectationError::InvalidSchema 659 ); 660 assert_eq!( 661 ConfigDocumentExpectation::new("radroots.example.config", 0).unwrap_err(), 662 ConfigDocumentExpectationError::InvalidSchemaVersion 663 ); 664 } 665 666 struct RecordingSource { 667 bytes: Vec<u8>, 668 opened: RefCell<Vec<PathBuf>>, 669 } 670 671 impl RecordingSource { 672 fn new(bytes: Vec<u8>) -> Self { 673 Self { 674 bytes, 675 opened: RefCell::new(Vec::new()), 676 } 677 } 678 } 679 680 impl DocumentSource for RecordingSource { 681 type Reader = Cursor<Vec<u8>>; 682 683 fn open_selected(&self, source_path: &Path) -> io::Result<Self::Reader> { 684 self.opened.borrow_mut().push(source_path.to_path_buf()); 685 Ok(Cursor::new(self.bytes.clone())) 686 } 687 } 688 689 #[test] 690 fn loader_performs_exactly_one_selected_read_through_its_only_io_capability() { 691 let source = RecordingSource::new(valid_document().as_bytes().to_vec()); 692 let selected = Path::new(SOURCE_PATH); 693 let loaded = load_with_source::<StrictDocument, _>(&source, selected, &expectation()) 694 .expect("selected document"); 695 696 assert_eq!(loaded.service.instance, "default"); 697 assert_eq!(source.opened.borrow().as_slice(), [selected]); 698 } 699 700 fn assert_public_error_chain_is_safe(error: &ConfigDocumentError, forbidden: &[&str]) { 701 let mut rendered = format!("{error:?}\n{error}"); 702 let mut source = error.source(); 703 while let Some(current) = source { 704 rendered.push_str(&format!("\n{current:?}\n{current}")); 705 source = current.source(); 706 } 707 for value in forbidden { 708 assert!(!rendered.contains(value), "public error leaked `{value}`"); 709 } 710 } 711 }