lib

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

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 }