From 997fa407522e498bddaa8ab49505c8299b457ab9 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 4 Nov 2026 05:53:54 +0200 Subject: [PATCH 1/3] fix(config): read an empty document as the defaults - serde-saphyr 2.1 refuses YAML null where a struct is expected, and a document without content (empty, only comments, a bare `---`) is null: every config reader refused a file with nothing set yet - the shared reader reads the document as an optional value and an absent one as an empty mapping; a document with content reads as before - serde-saphyr requirement 2.1; `an_empty_document_is_the_default_config` failed on 2.1.0 before the change Closes #165 --- Cargo.toml | 2 +- src/config.rs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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..14518e5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,8 +340,17 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result(yaml: &str) -> Result { + match parse_yaml_document::>(yaml)? { + Some(value) => Ok(value), + None => parse_yaml_document("{}"), + } +} + +fn parse_yaml_document(yaml: &str) -> Result { let mut path = None; serde_saphyr::with_deserializer_from_str_with_options( yaml, From 3e3f875d0f302b3efacacd129dbc72fdb2375927 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 4 Nov 2026 06:20:39 +0200 Subject: [PATCH 2/3] fix(config): keep an explicit null document as null - the reader tries the target type first and falls back to an empty mapping only when the type refuses a document that holds no node, so an optional reads `null` and an empty document as absent and an untyped value reads `null` as null - tests: an explicit null keeps its meaning, an empty document keeps an optional absent (both failed with the earlier fallback) --- src/config.rs | 26 ++++++++++++++++++++------ src/config/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index 14518e5..cc53c9b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,16 +340,30 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result(yaml: &str) -> Result { - match parse_yaml_document::>(yaml)? { - Some(value) => Ok(value), - None => parse_yaml_document("{}"), + match parse_yaml_document(yaml) { + Err(refused) if has_no_node(yaml) => parse_yaml_document("{}").map_err(|_| refused), + read => read, } } +/// 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( diff --git a/src/config/tests.rs b/src/config/tests.rs index 54092c9..c00c8eb 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -917,6 +917,38 @@ 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_repeated_key_is_refused() { // Which of two values would win is not something to guess for a config. From 50820e020a1f6487a3ba0ab0ef7432ad2de664e0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 4 Nov 2026 06:29:03 +0200 Subject: [PATCH 3/3] fix(config): read only one well-formed empty document as defaults - the empty-mapping reading also accepted text that only looked empty: two documents (`---\n---\n`) or a broken directive (`%YAML\n---\n`) became the default config instead of an error - it now needs the reader to accept the input as one document holding nothing, besides the text holding no explicit `null` - test: a malformed empty-looking document is refused (failed before) --- src/config.rs | 22 ++++++++++++++++++---- src/config/tests.rs | 8 ++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index cc53c9b..00834ff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,16 +340,30 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result(yaml: &str) -> Result { match parse_yaml_document(yaml) { - Err(refused) if has_no_node(yaml) => parse_yaml_document("{}").map_err(|_| refused), + 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 { diff --git a/src/config/tests.rs b/src/config/tests.rs index c00c8eb..cfd9f9b 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -949,6 +949,14 @@ fn an_empty_document_keeps_an_optional_absent() { } } +#[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.