Skip to content

Commit 2313691

Browse files
committed
Use custom serde visitor to fix store error messages
Background ---------- This change introduces a custom serde visitor to handle single-store and chained-store variants while still producing useful error messages. The [chained authority PR](hickory-dns#2161) used the serde [untagged enum representation](https://serde.rs/enum-representations.html#untagged) in order to represent single-store (TOML table, serde map) or chained-store (TOML array-of-tables, serde sequence) variants. Unfortunately, serde is not able to show useful error messages when a syntax error is encountered and untagged enums are being used. For example, misspelling the "type" option in a zone store configuration results in this error message to the user: ``` 1727879524:INFO:hickory_dns:443:loading configuration from: "tests/test-data/test_configs/chained_blocklist.toml" Error: failed to read config file from "tests/test-data/test_configs/chained_blocklist.toml": toml decode error: TOML parse error at line 38, column 1 | 38 | [[zones.stores]] | ^^^^^^^^^^^^^^^^ data did not match any variant of untagged enum StoreConfigContainer ``` By using a minimal custom visitor, we can restore the previous, more useful error messages: **Single Store** ``` 1728310440:INFO:hickory_dns:431:loading configuration from: "tests/test-data/test_configs/example_recursor.toml" Error: failed to read config file from "tests/test-data/test_configs/example_recursor.toml": toml decode error: TOML parse error at line 38, column 1 | 38 | [zones.stores] | ^^^^^^^^^^^^^^ missing field `type` ``` **Chained Stores** ``` 1728311606:INFO:hickory_dns:431:loading configuration from: "tests/test-data/test_configs/chained_blocklist.toml" Error: failed to read config file from "tests/test-data/test_configs/chained_blocklist.toml": toml decode error: TOML parse error at line 47, column 1 | 47 | [[zones.stores]] | ^^^^^^^^^^^^^^^^ missing field `type` ``` A side effect of this change is that StoreConfig is always represented as a Vec<StoreConfig>, which simplifies a bit of the code that iterates over the stores in bin/src/hickory-dns.rs. Other Options ------------- * File a bug report with the serde team. This has been done by others, and it appears the serde maintainers have no intention of fixing this in serde proper, rejecting multiple PRs that address this issue. See, for example, [here](serde-rs/serde#1544) * Use one of the work-around crates published by the serde team. 1. [Path to Error](https://docs.rs/serde_path_to_error/latest/serde_path_to_error/). This appears to work, but adds an additional crate to our dependency tree. 2. [Serde Untagged](https://docs.rs/serde-untagged/latest/serde_untagged/). Has unacceptable type limitations (it only supports bool, i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, f32, f64, char, string, borrowed_str, bytes, borrowed_bytes, byte_buf, unit, seq, map fields) * Make all stores an array-of-tables. In addition to breaking existing configurations, this seems counterintuitive to me. * Use a different configuration name for single- vs chained-stores: [zones.stores] for single stores and [[zones.chained_stores]] for chained stores. This still seems kind of clunky to me, particularly with the existing "stores" being plural for a single-store configuration, but is probably the next-best alternative.
1 parent 0217e8a commit 2313691

File tree

2 files changed

+138
-43
lines changed

2 files changed

+138
-43
lines changed

bin/src/hickory-dns.rs

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ use tracing_subscriber::{
6161

6262
#[cfg(feature = "dns-over-tls")]
6363
use hickory_dns::dnssec::{self, TlsCertConfig};
64-
use hickory_dns::{Config, StoreConfig, StoreConfigContainer, ZoneConfig};
64+
use hickory_dns::{Config, StoreConfig, ZoneConfig};
6565
use hickory_proto::rr::Name;
6666
#[cfg(feature = "blocklist")]
6767
use hickory_server::store::blocklist::BlocklistAuthority;
@@ -167,23 +167,14 @@ async fn load_zone(
167167
warn!("allow_update is deprecated in [[zones]] section, it belongs in [[zones.stores]]");
168168
}
169169

170-
let mut normalized_stores = vec![];
171-
if let Some(StoreConfigContainer::Single(store)) = &zone_config.stores {
172-
normalized_stores.push(store);
173-
} else if let Some(StoreConfigContainer::Chained(chained_stores)) = &zone_config.stores {
174-
for store in chained_stores {
175-
normalized_stores.push(store);
176-
}
177-
} else {
178-
normalized_stores.push(&StoreConfig::Default);
179-
debug!("No stores specified for {zone_name}, using default config processing");
180-
}
181-
182-
// load the zone and build a vector of associated authorities to load in the catalog.
183-
debug!("Loading authorities for {zone_name} with stores {normalized_stores:?}");
170+
// load the zone and insert any configured authorities in the catalog.
171+
debug!(
172+
"loading authorities for {zone_name} with stores {:?}",
173+
zone_config.stores
174+
);
184175

185176
let mut authorities: Vec<Arc<dyn AuthorityObject>> = vec![];
186-
for store in normalized_stores {
177+
for store in &zone_config.stores {
187178
let authority: Arc<dyn AuthorityObject> = match store {
188179
#[cfg(feature = "sqlite")]
189180
StoreConfig::Sqlite(config) => {

bin/src/lib.rs

Lines changed: 131 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
1010
pub mod dnssec;
1111

12-
use std::fs::File;
13-
use std::io::Read;
14-
use std::net::{AddrParseError, Ipv4Addr, Ipv6Addr};
15-
use std::path::{Path, PathBuf};
16-
use std::str::FromStr;
17-
use std::time::Duration;
12+
use std::{
13+
fmt,
14+
fs::File,
15+
io::Read,
16+
net::{AddrParseError, Ipv4Addr, Ipv6Addr},
17+
path::{Path, PathBuf},
18+
str::FromStr,
19+
time::Duration,
20+
};
1821

1922
use cfg_if::cfg_if;
2023
use ipnet::IpNet;
21-
use serde::{self, Deserialize};
24+
use serde::de::{self, MapAccess, SeqAccess, Visitor};
25+
use serde::{self, Deserialize, Deserializer};
2226

2327
use hickory_proto::error::ProtoResult;
2428
use hickory_proto::rr::Name;
@@ -250,9 +254,14 @@ pub struct ZoneConfig {
250254
/// Keys for use by the zone
251255
#[serde(default)]
252256
pub keys: Vec<dnssec::KeyConfig>,
253-
/// Store configurations, TODO: allow chained Stores
254-
#[serde(default)]
255-
pub stores: Option<StoreConfigContainer>,
257+
/// Store configurations. Note: we specify a default handler to get a Vec containing a
258+
/// StoreConfig::Default, which is used for authoritative file-based zones and legacy sqlite
259+
/// configurations. #[serde(default)] cannot be used, because it will invoke Default for Vec,
260+
/// i.e., an empty Vec and we cannot implement Default for StoreConfig and return a Vec. The
261+
/// custom visitor is used to handle map (single store) or sequence (chained store) configurations.
262+
#[serde(default = "store_config_default")]
263+
#[serde(deserialize_with = "store_config_visitor")]
264+
pub stores: Vec<StoreConfig>,
256265
/// The kind of non-existence proof provided by the nameserver
257266
#[cfg(feature = "dnssec")]
258267
pub nx_proof_kind: Option<NxProofKind>,
@@ -290,7 +299,7 @@ impl ZoneConfig {
290299
allow_axfr,
291300
enable_dnssec,
292301
keys,
293-
stores: None,
302+
stores: store_config_default(),
294303
#[cfg(feature = "dnssec")]
295304
nx_proof_kind,
296305
}
@@ -345,22 +354,6 @@ impl ZoneConfig {
345354
}
346355
}
347356

348-
/// Enumeration over all Store configurations
349-
///
350-
/// This is the outer container enum, covering the single- and chained-store variants.
351-
/// The chained store variant is a vector of StoreConfigs that should be consulted in-order during the lookup process.
352-
/// An example of this (currently the only example,) is when the blocklist feature is used: the blocklist should be queried first, then
353-
/// a recursor or forwarder second if the blocklist authority does not match on the query.
354-
#[derive(Deserialize, PartialEq, Eq, Debug)]
355-
#[serde(untagged)]
356-
#[non_exhaustive]
357-
pub enum StoreConfigContainer {
358-
/// For a zone with a single store
359-
Single(StoreConfig),
360-
/// For a zone with multiple stores. E.g., a recursive or forwarding zone with block lists.
361-
Chained(Vec<StoreConfig>),
362-
}
363-
364357
/// Enumeration over all store types
365358
#[derive(Deserialize, PartialEq, Eq, Debug)]
366359
#[serde(tag = "type")]
@@ -388,8 +381,89 @@ pub enum StoreConfig {
388381
Default,
389382
}
390383

384+
/// Create a default value for serde for StoreConfig.
385+
fn store_config_default() -> Vec<StoreConfig> {
386+
vec![StoreConfig::Default]
387+
}
388+
389+
/// Custom serde visitor that can deserialize a map (single configuration store, expressed as a TOML
390+
/// table) or sequence (chained configuration stores, expressed as a TOML array of tables.)
391+
/// This is used instead of an untagged enum because serde cannot provide variant-specific error
392+
/// messages when using an untagged enum.
393+
fn store_config_visitor<'de, D>(deserializer: D) -> Result<Vec<StoreConfig>, D::Error>
394+
where
395+
D: Deserializer<'de>,
396+
{
397+
struct MapOrSequence;
398+
399+
impl<'de> Visitor<'de> for MapOrSequence {
400+
type Value = Vec<StoreConfig>;
401+
402+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
403+
formatter.write_str("map or sequence")
404+
}
405+
406+
fn visit_seq<S>(self, seq: S) -> Result<Vec<StoreConfig>, S::Error>
407+
where
408+
S: SeqAccess<'de>,
409+
{
410+
Deserialize::deserialize(de::value::SeqAccessDeserializer::new(seq))
411+
}
412+
413+
fn visit_map<M>(self, map: M) -> Result<Vec<StoreConfig>, M::Error>
414+
where
415+
M: MapAccess<'de>,
416+
{
417+
match Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)) {
418+
Ok(seq) => Ok(vec![seq]),
419+
Err(e) => Err(e),
420+
}
421+
}
422+
}
423+
424+
deserializer.deserialize_any(MapOrSequence)
425+
}
426+
391427
#[cfg(test)]
392428
mod tests {
429+
const SINGLE_STORE_CONFIG_BAD: &str = r#"
430+
[[zones]]
431+
zone = "."
432+
zone_type = "Forward"
433+
434+
[zones.stores]
435+
ype = "forward"
436+
"#;
437+
438+
const CHAINED_STORE_CONFIG_BAD: &str = r#"
439+
[[zones]]
440+
zone = "."
441+
zone_type = "Forward"
442+
443+
[[zones.stores]]
444+
type = "forward"
445+
446+
[[zones.stores.name_servers]]
447+
socket_addr = "8.8.8.8:53"
448+
protocol = "udp"
449+
trust_negative_responses = false
450+
451+
[[zones.stores]]
452+
type = "forward"
453+
454+
[[zones.stores.name_servers]]
455+
socket_addr = "1.1.1.1:53"
456+
rotocol = "udp"
457+
trust_negative_responses = false
458+
"#;
459+
460+
const EMPTY_STORE_CONFIG: &str = r#"
461+
[[zones]]
462+
zone = "localhost"
463+
zone_type = "Primary"
464+
file = "default/localhost.zone"
465+
"#;
466+
393467
#[cfg(feature = "recursor")]
394468
#[test]
395469
fn example_recursor_config() {
@@ -398,4 +472,34 @@ mod tests {
398472
))
399473
.unwrap();
400474
}
475+
476+
#[cfg(feature = "resolver")]
477+
#[test]
478+
fn single_store_config_error_message() {
479+
match toml::from_str::<super::Config>(SINGLE_STORE_CONFIG_BAD) {
480+
Ok(val) => panic!("expected error value; got ok: {val:?}"),
481+
Err(e) => assert!(e.to_string().contains("missing field `type`")),
482+
}
483+
}
484+
485+
#[cfg(feature = "resolver")]
486+
#[test]
487+
fn chained_store_config_error_message() {
488+
match toml::from_str::<super::Config>(CHAINED_STORE_CONFIG_BAD) {
489+
Ok(val) => panic!("expected error value; got ok: {val:?}"),
490+
Err(e) => assert!(e.to_string().contains("unknown field `rotocol`")),
491+
}
492+
}
493+
494+
#[cfg(feature = "resolver")]
495+
#[test]
496+
fn empty_store_default_value() {
497+
match toml::from_str::<super::Config>(EMPTY_STORE_CONFIG) {
498+
Ok(val) => {
499+
assert_eq!(val.zones[0].stores.len(), 1);
500+
assert_eq!(val.zones[0].stores[0], super::StoreConfig::Default);
501+
}
502+
Err(e) => panic!("expected successful parse: {e:?}"),
503+
}
504+
}
401505
}

0 commit comments

Comments
 (0)