diff --git a/Cargo.toml b/Cargo.toml index 9bdc4d7..a223bff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # The config file and the sections an embedder hands over as YAML. Safe Rust; # deserialization only. -serde-saphyr = { version = "2.0", default-features = false, features = ["deserialize"] } +serde-saphyr = { version = "2.1", default-features = false, features = ["deserialize"] } # The key path (`runtime.worker_threads`) a config error names; already in the # tree through axum. serde_path_to_error = "0.1" diff --git a/src/config.rs b/src/config.rs index d63f9b1..00834ff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,8 +340,45 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result(yaml: &str) -> Result { + match parse_yaml_document(yaml) { + Err(refused) if is_one_empty_document(yaml) => { + parse_yaml_document("{}").map_err(|_| refused) + } + read => read, + } +} + +/// Whether `yaml` is a single document the reader accepts and that holds no +/// node: the reader checks the syntax and the document count, the text that +/// nothing in it is an explicit `null`. +fn is_one_empty_document(yaml: &str) -> bool { + has_no_node(yaml) + && matches!( + parse_yaml_document::>(yaml), + Ok(None) + ) +} + +/// Whether `yaml` holds no node: blank and comment lines, document markers +/// (`---`, `...`) with at most a comment after them, and directives. +fn has_no_node(yaml: &str) -> bool { + yaml.lines().all(|line| { + let line = line.trim(); + let rest = line + .strip_prefix("---") + .or_else(|| line.strip_prefix("...")) + .unwrap_or(line) + .trim_start(); + rest.is_empty() || rest.starts_with('#') || line.starts_with('%') + }) +} + +fn parse_yaml_document(yaml: &str) -> Result { let mut path = None; serde_saphyr::with_deserializer_from_str_with_options( yaml, diff --git a/src/config/tests.rs b/src/config/tests.rs index 54092c9..cfd9f9b 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -917,6 +917,46 @@ fn an_empty_document_is_the_default_config() { } } +#[test] +fn an_explicit_null_document_keeps_its_meaning() { + // `null` is a node, unlike an empty document: an optional reads it as + // absent and an untyped value as null, never as an empty mapping. + for yaml in ["null", "~", "--- null\n"] { + assert_eq!( + parse_yaml::>(yaml).unwrap(), + None, + "{yaml:?}" + ); + assert_eq!( + parse_yaml::(yaml).unwrap(), + serde_json::Value::Null, + "{yaml:?}" + ); + } + // A config is a mapping; an explicit null is not one. + assert!(ProxyConfig::from_yaml_str("null").is_err()); +} + +#[test] +fn an_empty_document_keeps_an_optional_absent() { + // The empty-mapping reading applies only when the type refuses no node. + for yaml in ["", "# nothing\n", "---\n"] { + assert_eq!( + parse_yaml::>(yaml).unwrap(), + None, + "{yaml:?}" + ); + } +} + +#[test] +fn a_malformed_empty_looking_document_is_refused() { + // Two documents or a broken directive are errors, not an empty config. + for yaml in ["---\n---\n", "%YAML\n---\n"] { + assert!(ProxyConfig::from_yaml_str(yaml).is_err(), "{yaml:?}"); + } +} + #[test] fn a_repeated_key_is_refused() { // Which of two values would win is not something to guess for a config.