1use std::{fmt::Write, time::Duration, time::Instant};
14
15use clap::Args;
16use hickory_resolver::{
17 Resolver, config::ResolverOpts, net::runtime::TokioRuntimeProvider, proto::rr::Record,
18 proto::rr::RecordType,
19};
20use serde::Serialize;
21use serde_json::json;
22
23use crate::{
24 control_plane::config::{AppConfig, DnsServerConfig, ValidationTransport},
25 core::{
26 dns::{
27 resolver::{ResolverKind, ResolverTarget, build_resolver, classify_hickory_error},
28 validation::{ObservedRecord, ValidationFailureKind},
29 },
30 error::{Error, Result},
31 },
32};
33
34const DEFAULT_TIMEOUT_MS: u64 = 5_000;
37
38pub const TRANSPORT_PRECEDENCE: [ValidationTransport; 4] = [
42 ValidationTransport::Doh,
43 ValidationTransport::Dot,
44 ValidationTransport::Dns,
45 ValidationTransport::Doq,
46];
47
48const DEFAULT_RECORD_TYPES: [&str; 10] = [
49 "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR", "SOA",
50];
51
52#[derive(Args, Debug, Clone, Default)]
53pub struct QueryArgs {
54 pub targets: Vec<String>,
58
59 #[arg(short = 't', long = "type", value_name = "RR")]
64 pub r#type: Vec<String>,
65
66 #[arg(long)]
70 pub server: Option<String>,
71
72 #[arg(long)]
76 pub at: Option<String>,
77
78 #[arg(long)]
81 pub dns: bool,
82
83 #[arg(long)]
85 pub dot: bool,
86
87 #[arg(long)]
89 pub doh: bool,
90
91 #[arg(long)]
94 pub doq: bool,
95
96 #[arg(long)]
100 pub all: bool,
101
102 #[arg(long)]
105 pub port: Option<u16>,
106
107 #[arg(long = "tls-server-name")]
110 pub tls_server_name: Option<String>,
111
112 #[arg(long)]
114 pub timeout: Option<u64>,
115
116 #[arg(long)]
119 pub tcp: bool,
120
121 #[arg(long)]
123 pub short: bool,
124
125 #[arg(long)]
127 pub json: bool,
128}
129
130#[derive(Debug, Clone)]
134pub struct QueryResultBlock {
135 pub target_label: String,
136 pub transport: ValidationTransport,
137 pub extras: Vec<(String, String)>,
138 pub url: Option<String>,
139 pub host_for_json: Option<String>,
140 pub port_for_json: Option<u16>,
141 pub elapsed: Duration,
142 pub status: QueryStatus,
143 pub records: Vec<ObservedRecord>,
144 pub asked_types: Vec<String>,
145 pub queried_name: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum QueryStatus {
153 NoError,
154 NxDomain,
155 Servfail,
156 Refused,
157 Timeout,
158 TlsFailure,
159 DohHttpFailure,
160 MalformedResponse,
161 UnsupportedTransport,
162 Skipped { reason: String },
163}
164
165impl QueryStatus {
166 fn header_word(&self) -> Option<&str> {
167 Some(match self {
168 QueryStatus::NoError => return None,
169 QueryStatus::NxDomain => "NXDOMAIN",
170 QueryStatus::Servfail => "SERVFAIL",
171 QueryStatus::Refused => "REFUSED",
172 QueryStatus::Timeout => "TIMEOUT",
173 QueryStatus::TlsFailure => "TLS_FAILURE",
174 QueryStatus::DohHttpFailure => "HTTP_FAILURE",
175 QueryStatus::MalformedResponse => "MALFORMED",
176 QueryStatus::UnsupportedTransport => "UNSUPPORTED",
177 QueryStatus::Skipped { .. } => "SKIPPED",
178 })
179 }
180
181 fn json_tag(&self) -> &'static str {
182 match self {
183 QueryStatus::NoError => "noerror",
184 QueryStatus::NxDomain => "nxdomain",
185 QueryStatus::Servfail => "servfail",
186 QueryStatus::Refused => "refused",
187 QueryStatus::Timeout => "timeout",
188 QueryStatus::TlsFailure => "tls_failure",
189 QueryStatus::DohHttpFailure => "doh_http_failure",
190 QueryStatus::MalformedResponse => "malformed_response",
191 QueryStatus::UnsupportedTransport => "unsupported_transport",
192 QueryStatus::Skipped { .. } => "skipped",
193 }
194 }
195
196 fn severity(&self) -> u8 {
199 match self {
200 QueryStatus::NoError => 0,
201 QueryStatus::Skipped { .. } => 1,
202 QueryStatus::NxDomain => 2,
203 QueryStatus::Servfail
204 | QueryStatus::Refused
205 | QueryStatus::Timeout
206 | QueryStatus::TlsFailure
207 | QueryStatus::DohHttpFailure
208 | QueryStatus::MalformedResponse
209 | QueryStatus::UnsupportedTransport => 3,
210 }
211 }
212}
213
214impl From<ValidationFailureKind> for QueryStatus {
215 fn from(kind: ValidationFailureKind) -> Self {
216 match kind {
217 ValidationFailureKind::Timeout => QueryStatus::Timeout,
218 ValidationFailureKind::Nxdomain => QueryStatus::NxDomain,
219 ValidationFailureKind::Servfail => QueryStatus::Servfail,
220 ValidationFailureKind::Refused => QueryStatus::Refused,
221 ValidationFailureKind::TlsFailure => QueryStatus::TlsFailure,
222 ValidationFailureKind::DohHttpFailure => QueryStatus::DohHttpFailure,
223 ValidationFailureKind::MalformedResponse => QueryStatus::MalformedResponse,
224 ValidationFailureKind::UnsupportedTransport => QueryStatus::UnsupportedTransport,
225 }
226 }
227}
228
229pub async fn run_query(config: Option<AppConfig>, args: QueryArgs) -> Result<i32> {
235 let outcome = execute_query(config, args.clone()).await?;
236
237 if args.json {
238 print_json(
239 &outcome.domain,
240 &outcome.record_types,
241 &outcome.target_kind,
242 &outcome.blocks,
243 );
244 } else if args.short {
245 print_short(&outcome.blocks);
246 } else {
247 print_table(&outcome.blocks, &outcome.record_types);
248 }
249
250 Ok(exit_code_for(&outcome.blocks))
251}
252
253pub async fn execute_query(config: Option<AppConfig>, args: QueryArgs) -> Result<QueryOutcome> {
258 let (domain, ad_hoc_from_positional) = split_targets(&args.targets)?;
259 let mut effective = args;
260 if let Some(at) = ad_hoc_from_positional {
261 if effective.at.is_some() {
262 return Err(Error::parse(
263 "ambiguous resolver target: pass either `@ADDR` or `--at <ADDR>`, not both",
264 ));
265 }
266 effective.at = Some(at);
267 }
268
269 validate_cli_rules(&effective)?;
270
271 let record_types = parse_record_types(&effective.r#type)?;
272 let default_timeout = Duration::from_millis(effective.timeout.unwrap_or(DEFAULT_TIMEOUT_MS));
273
274 let plan = build_query_plan(config.as_ref(), &effective, default_timeout)?;
275
276 let mut blocks = Vec::with_capacity(plan.targets.len());
277 for plan_target in plan.targets {
278 blocks.push(run_block(plan_target, &record_types, &domain).await);
279 }
280
281 Ok(QueryOutcome {
282 domain,
283 record_types,
284 target_kind: plan.kind,
285 blocks,
286 })
287}
288
289#[derive(Debug, Clone)]
292pub struct QueryOutcome {
293 pub domain: String,
294 pub record_types: Vec<String>,
295 pub target_kind: TargetKind,
296 pub blocks: Vec<QueryResultBlock>,
297}
298
299impl QueryOutcome {
300 pub fn to_json(&self) -> serde_json::Value {
303 build_json_value(
304 &self.domain,
305 &self.record_types,
306 &self.target_kind,
307 &self.blocks,
308 )
309 }
310}
311
312fn split_targets(positionals: &[String]) -> Result<(String, Option<String>)> {
314 let mut domain: Option<&str> = None;
315 let mut at: Option<String> = None;
316 for raw in positionals {
317 if let Some(rest) = raw.strip_prefix('@') {
318 if at.is_some() {
319 return Err(Error::parse("only one `@ADDR` positional is accepted"));
320 }
321 if rest.is_empty() {
322 return Err(Error::parse("`@ADDR` is missing an address after `@`"));
323 }
324 at = Some(rest.to_string());
325 } else if domain.is_none() {
326 domain = Some(raw);
327 } else {
328 return Err(Error::parse(format!(
329 "unexpected positional argument '{raw}': pass a single domain plus an optional `@ADDR`",
330 )));
331 }
332 }
333 let Some(domain) = domain else {
334 return Err(Error::parse(
335 "missing required positional `<DOMAIN>` (the name to resolve)",
336 ));
337 };
338 Ok((domain.to_string(), at))
339}
340
341fn validate_cli_rules(args: &QueryArgs) -> Result<()> {
342 if args.server.is_some() && args.at.is_some() {
343 return Err(Error::parse(
344 "`--server` and `--at`/`@ADDR` are mutually exclusive",
345 ));
346 }
347
348 let any_transport = args.dns || args.dot || args.doh || args.doq;
349 let has_target = args.server.is_some() || args.at.is_some();
350
351 if args.all && (args.dns || args.dot || args.doh || args.doq) {
352 return Err(Error::parse(
353 "`--all` is mutually exclusive with `--dns` / `--dot` / `--doh` / `--doq`",
354 ));
355 }
356
357 if args.all && args.server.is_none() {
358 return Err(Error::parse(
359 "`--all` requires `--server <ID>` — there's no way to enumerate transports for an ad-hoc target or the system resolver",
360 ));
361 }
362
363 if !has_target && (any_transport || args.all) {
364 return Err(Error::parse(
365 "transport flags (--dns/--dot/--doh/--doq/--all) require a resolver target — pass --server <ID> or --at <ADDR>",
366 ));
367 }
368
369 if args.at.is_some() && (args.dns as u8 + args.dot as u8 + args.doh as u8 + args.doq as u8) > 1
370 {
371 return Err(Error::parse(
372 "with `--at`/`@ADDR`, at most one of --dns/--dot/--doh/--doq is accepted",
373 ));
374 }
375
376 if args.server.is_some() && (args.port.is_some() || args.tls_server_name.is_some() || args.tcp)
377 {
378 return Err(Error::parse(
379 "`--port` / `--tls-server-name` / `--tcp` only apply to ad-hoc resolvers (`--at` / `@ADDR`); for `--server`, the transport block owns those values",
380 ));
381 }
382
383 Ok(())
384}
385
386fn parse_record_types(input: &[String]) -> Result<Vec<String>> {
387 if input.is_empty() {
388 return Ok(DEFAULT_RECORD_TYPES
389 .iter()
390 .map(|rr_type| (*rr_type).to_string())
391 .collect());
392 }
393 let mut out = Vec::with_capacity(input.len());
394 for raw in input {
395 let upper = raw.trim().to_ascii_uppercase();
396 if upper.is_empty() {
397 return Err(Error::parse("--type cannot be empty"));
398 }
399 upper
400 .parse::<RecordType>()
401 .map_err(|_| Error::parse(format!("unknown record type '{raw}'")))?;
402 if !out.contains(&upper) {
403 out.push(upper);
404 }
405 }
406 Ok(out)
407}
408
409struct QueryPlan {
412 kind: TargetKind,
413 targets: Vec<PlanTarget>,
414}
415
416struct PlanTarget {
417 transport: ValidationTransport,
418 target: Option<ResolverTarget>,
422 target_label: String,
423 extras: Vec<(String, String)>,
424 url: Option<String>,
425 host_for_json: Option<String>,
426 port_for_json: Option<u16>,
427 timeout: Duration,
428 skip_reason: Option<String>,
429}
430
431#[derive(Debug, Clone)]
432pub enum TargetKind {
433 System {
434 display: String,
435 },
436 Named {
437 server_id: String,
438 cluster: Option<String>,
439 },
440 AdHoc,
441}
442
443fn build_query_plan(
444 config: Option<&AppConfig>,
445 args: &QueryArgs,
446 timeout: Duration,
447) -> Result<QueryPlan> {
448 if let Some(server_id) = args.server.as_deref() {
449 return build_named_plan(config, server_id, args, timeout);
450 }
451 if let Some(at) = args.at.as_deref() {
452 return build_ad_hoc_plan(at, args, timeout);
453 }
454 build_system_plan(args, timeout)
455}
456
457fn build_system_plan(_args: &QueryArgs, timeout: Duration) -> Result<QueryPlan> {
458 let display = system_resolver_display();
459 let mut extras = Vec::new();
463 extras.push(("system".to_string(), String::new()));
464 Ok(QueryPlan {
465 kind: TargetKind::System {
466 display: display.clone(),
467 },
468 targets: vec![PlanTarget {
469 transport: ValidationTransport::Dns,
470 target: None,
471 target_label: display,
472 extras,
473 url: None,
474 host_for_json: None,
475 port_for_json: None,
476 timeout,
477 skip_reason: Some("__system__".to_string()),
478 }],
479 })
480}
481
482fn system_resolver_display() -> String {
486 match hickory_resolver::system_conf::read_system_conf() {
487 Ok((config, _)) => {
488 let mut servers = config
489 .name_servers()
490 .iter()
491 .map(|ns| ns.ip.to_string())
492 .collect::<Vec<_>>();
493 servers.sort();
494 servers.dedup();
495 if servers.is_empty() {
496 "system".to_string()
497 } else if servers.len() == 1 {
498 servers.into_iter().next().unwrap()
499 } else {
500 servers.join(",")
501 }
502 }
503 Err(_) => "system".to_string(),
504 }
505}
506
507fn build_named_plan(
508 config: Option<&AppConfig>,
509 server_id: &str,
510 args: &QueryArgs,
511 timeout: Duration,
512) -> Result<QueryPlan> {
513 let cfg = config.ok_or_else(|| {
514 Error::parse(format!(
515 "--server {server_id} requires a config file; none was loaded",
516 ))
517 })?;
518
519 if cfg.clusters.contains_key(server_id) {
520 let members = cfg
521 .clusters
522 .get(server_id)
523 .map(|c| c.members.join(", "))
524 .unwrap_or_default();
525 return Err(Error::parse(format!(
526 "'{server_id}' is a cluster id, not a server. Pick one of its members ({members}) with --server",
527 )));
528 }
529
530 let server = cfg.selected_server(Some(server_id))?;
531 let mut transports = chosen_transports(args);
532 transports.sort_by_key(|t| precedence_index(*t));
533 if !args.all
534 && !has_explicit_transport(args)
535 && let Some(best) = transports
536 .iter()
537 .copied()
538 .find(|transport| ResolverTarget::is_enabled_on(server, *transport))
539 {
540 transports = vec![best];
541 }
542
543 let mut plan_targets = Vec::new();
544 for transport in transports {
545 let block_enabled = ResolverTarget::is_enabled_on(server, transport);
546 if !block_enabled {
547 if args.all {
548 continue;
549 }
550 plan_targets.push(skipped_plan_target(
551 transport,
552 server,
553 "block not configured or disabled",
554 timeout,
555 ));
556 continue;
557 }
558 let Some(mut target) = ResolverTarget::from_server_block(server, transport) else {
559 if args.all {
560 continue;
561 }
562 plan_targets.push(skipped_plan_target(
563 transport,
564 server,
565 "block not configured",
566 timeout,
567 ));
568 continue;
569 };
570 if let Some(override_ms) = args.timeout {
571 target.timeout = Duration::from_millis(override_ms);
572 } else {
573 if target.timeout == Duration::ZERO {
576 target.timeout = timeout;
577 }
578 }
579 let (label, extras, url, host_for_json, port_for_json) = describe_target(&target);
580 let target_timeout = target.timeout;
581 plan_targets.push(PlanTarget {
582 transport,
583 target: Some(target),
584 target_label: label,
585 extras,
586 url,
587 host_for_json,
588 port_for_json,
589 timeout: target_timeout,
590 skip_reason: None,
591 });
592 }
593
594 Ok(QueryPlan {
595 kind: TargetKind::Named {
596 server_id: server.id.clone(),
597 cluster: server.cluster.clone(),
598 },
599 targets: plan_targets,
600 })
601}
602
603fn skipped_plan_target(
604 transport: ValidationTransport,
605 server: &DnsServerConfig,
606 reason: &str,
607 timeout: Duration,
608) -> PlanTarget {
609 PlanTarget {
610 transport,
611 target: None,
612 target_label: format!(
613 "— (no [servers.{}] on {})",
614 transport_word(transport),
615 server.id
616 ),
617 extras: Vec::new(),
618 url: None,
619 host_for_json: None,
620 port_for_json: None,
621 timeout,
622 skip_reason: Some(reason.to_string()),
623 }
624}
625
626fn has_explicit_transport(args: &QueryArgs) -> bool {
627 args.dns || args.dot || args.doh || args.doq
628}
629
630fn chosen_transports(args: &QueryArgs) -> Vec<ValidationTransport> {
631 let any_explicit = has_explicit_transport(args);
632 if args.all {
633 return TRANSPORT_PRECEDENCE.to_vec();
634 }
635 if !any_explicit {
636 return TRANSPORT_PRECEDENCE.to_vec();
639 }
640 let mut out = Vec::new();
641 if args.doh {
642 out.push(ValidationTransport::Doh);
643 }
644 if args.dot {
645 out.push(ValidationTransport::Dot);
646 }
647 if args.dns {
648 out.push(ValidationTransport::Dns);
649 }
650 if args.doq {
651 out.push(ValidationTransport::Doq);
652 }
653 out
654}
655
656fn build_ad_hoc_plan(at: &str, args: &QueryArgs, timeout: Duration) -> Result<QueryPlan> {
657 let parsed = parse_ad_hoc(at)?;
658 let forced = forced_transport_from_flags(args);
659 let transport = match (parsed.transport, forced) {
660 (Some(parsed_t), Some(forced_t)) if parsed_t != forced_t => {
661 return Err(Error::parse(format!(
662 "ad-hoc target scheme implies {parsed_t:?} but a different transport flag was supplied",
663 )));
664 }
665 (_, Some(t)) | (Some(t), None) => t,
666 (None, None) => ValidationTransport::Dns,
667 };
668
669 let mut target = ResolverTarget {
670 kind: ResolverKind::AdHoc,
671 transport,
672 host: parsed.host.clone(),
673 port: args.port.or(parsed.port),
674 url: parsed.url.clone(),
675 server_name: args.tls_server_name.clone(),
676 tcp_only: transport == ValidationTransport::Dns && args.tcp,
677 timeout,
678 };
679 if let Some(override_ms) = args.timeout {
680 target.timeout = Duration::from_millis(override_ms);
681 }
682
683 let (label, extras, url, host_for_json, port_for_json) = describe_target(&target);
684 let target_timeout = target.timeout;
685 Ok(QueryPlan {
686 kind: TargetKind::AdHoc,
687 targets: vec![PlanTarget {
688 transport,
689 target: Some(target),
690 target_label: label,
691 extras,
692 url,
693 host_for_json,
694 port_for_json,
695 timeout: target_timeout,
696 skip_reason: None,
697 }],
698 })
699}
700
701fn forced_transport_from_flags(args: &QueryArgs) -> Option<ValidationTransport> {
702 if args.doh {
703 Some(ValidationTransport::Doh)
704 } else if args.dot {
705 Some(ValidationTransport::Dot)
706 } else if args.dns {
707 Some(ValidationTransport::Dns)
708 } else if args.doq {
709 Some(ValidationTransport::Doq)
710 } else {
711 None
712 }
713}
714
715#[derive(Debug, Default)]
716struct ParsedAdHoc {
717 transport: Option<ValidationTransport>,
718 host: Option<String>,
719 port: Option<u16>,
720 url: Option<String>,
721}
722
723fn parse_ad_hoc(raw: &str) -> Result<ParsedAdHoc> {
724 let trimmed = raw.trim();
725 if trimmed.is_empty() {
726 return Err(Error::parse("--at value is empty"));
727 }
728
729 if let Some((scheme, rest)) = trimmed.split_once("://") {
730 let scheme = scheme.to_ascii_lowercase();
731 let (transport, is_url_transport) = match scheme.as_str() {
732 "udp" | "tcp" | "dns" => (Some(ValidationTransport::Dns), false),
733 "tls" | "dot" => (Some(ValidationTransport::Dot), false),
734 "https" | "doh" => (Some(ValidationTransport::Doh), true),
735 "quic" | "doq" => (Some(ValidationTransport::Doq), false),
736 other => {
737 return Err(Error::parse(format!(
738 "unknown ad-hoc scheme '{other}'; expected one of udp/tcp/dns/tls/dot/https/doh/quic/doq",
739 )));
740 }
741 };
742 if is_url_transport {
743 let url = if scheme == "doh" {
744 format!("https://{rest}")
745 } else {
746 trimmed.to_string()
747 };
748 return Ok(ParsedAdHoc {
749 transport,
750 host: None,
751 port: None,
752 url: Some(url),
753 });
754 }
755 let (host, port) = split_addr(rest)?;
756 return Ok(ParsedAdHoc {
757 transport,
758 host: Some(host),
759 port,
760 url: None,
761 });
762 }
763
764 let (host, port) = split_addr(trimmed)?;
765 Ok(ParsedAdHoc {
766 transport: None,
767 host: Some(host),
768 port,
769 url: None,
770 })
771}
772
773fn split_addr(raw: &str) -> Result<(String, Option<u16>)> {
774 let raw = raw.trim();
775 if raw.is_empty() {
776 return Err(Error::parse("ad-hoc target is empty"));
777 }
778 if let Some(stripped) = raw.strip_prefix('[') {
779 let (host, rest) = stripped
780 .split_once(']')
781 .ok_or_else(|| Error::parse("unmatched `[` in IPv6 literal"))?;
782 let port = rest
783 .strip_prefix(':')
784 .map(|p| {
785 p.parse::<u16>()
786 .map_err(|_| Error::parse(format!("invalid port '{p}'")))
787 })
788 .transpose()?;
789 return Ok((host.to_string(), port));
790 }
791 if let Some((host, port_s)) = raw.rsplit_once(':')
792 && !host.is_empty()
793 && !host.contains(':')
794 {
795 let port = port_s
796 .parse::<u16>()
797 .map_err(|_| Error::parse(format!("invalid port '{port_s}'")))?;
798 return Ok((host.to_string(), Some(port)));
799 }
800 Ok((raw.to_string(), None))
801}
802
803fn describe_target(
804 target: &ResolverTarget,
805) -> (
806 String,
807 Vec<(String, String)>,
808 Option<String>,
809 Option<String>,
810 Option<u16>,
811) {
812 let mut extras: Vec<(String, String)> = Vec::new();
813 let (label, url_for_json, host_for_json, port_for_json) = match target.transport {
814 ValidationTransport::Doh => {
815 let url = target.url.clone();
816 let label = url
817 .as_deref()
818 .map(strip_https_scheme_for_display)
819 .unwrap_or_else(|| target.host.clone().unwrap_or_default());
820 if let Some(name) = target.server_name.as_deref()
821 && !name.is_empty()
822 && !label.starts_with(name)
823 {
824 extras.push(("sni".to_string(), name.to_string()));
825 }
826 (label, url, target.host.clone(), target.port)
827 }
828 ValidationTransport::Dot | ValidationTransport::Doq => {
829 let port = target.port.unwrap_or(853);
830 let label = format!("{}:{}", target.host.clone().unwrap_or_default(), port);
831 if let Some(name) = target.server_name.as_deref()
832 && !name.is_empty()
833 {
834 extras.push(("sni".to_string(), name.to_string()));
835 }
836 (label, None, target.host.clone(), Some(port))
837 }
838 ValidationTransport::Dns => {
839 let port = target.port.unwrap_or(53);
840 let host = target.host.clone().unwrap_or_default();
841 let label = if port == 53 {
842 host.clone()
843 } else {
844 format!("{host}:{port}")
845 };
846 (label, None, target.host.clone(), Some(port))
847 }
848 };
849 (label, extras, url_for_json, host_for_json, port_for_json)
850}
851
852fn strip_https_scheme_for_display(url: &str) -> String {
853 url.strip_prefix("https://")
854 .map(str::to_string)
855 .unwrap_or_else(|| url.to_string())
856}
857
858fn precedence_index(t: ValidationTransport) -> u8 {
859 TRANSPORT_PRECEDENCE
860 .iter()
861 .position(|p| *p == t)
862 .map(|i| i as u8)
863 .unwrap_or(255)
864}
865
866fn transport_word(t: ValidationTransport) -> &'static str {
867 match t {
868 ValidationTransport::Dns => "dns",
869 ValidationTransport::Dot => "dot",
870 ValidationTransport::Doh => "doh",
871 ValidationTransport::Doq => "doq",
872 }
873}
874
875async fn run_block(plan: PlanTarget, record_types: &[String], domain: &str) -> QueryResultBlock {
876 let started = Instant::now();
877 let asked_types = record_types.to_vec();
878 let queried_name = domain.to_string();
879 let status_for_skip = plan.skip_reason.clone();
880
881 let finish = |status: QueryStatus, records: Vec<ObservedRecord>| QueryResultBlock {
882 target_label: plan.target_label.clone(),
883 transport: plan.transport,
884 extras: plan.extras.clone(),
885 url: plan.url.clone(),
886 host_for_json: plan.host_for_json.clone(),
887 port_for_json: plan.port_for_json,
888 elapsed: started.elapsed(),
889 status,
890 records,
891 asked_types: asked_types.clone(),
892 queried_name: queried_name.clone(),
893 };
894
895 if plan.skip_reason.as_deref() == Some("__system__") {
897 let resolver = match build_system_resolver(plan.timeout) {
898 Ok(r) => r,
899 Err(status) => return finish(status, Vec::new()),
900 };
901 let (status, records) = lookup_all(&resolver, domain, record_types, plan.transport).await;
902 return finish(status, records);
903 }
904
905 let Some(mut target) = plan.target.clone() else {
906 return finish(
907 QueryStatus::Skipped {
908 reason: status_for_skip.unwrap_or_else(|| "skipped".to_string()),
909 },
910 Vec::new(),
911 );
912 };
913
914 if target.transport == ValidationTransport::Doh
919 && target
920 .host
921 .as_deref()
922 .is_none_or(|h| h.parse::<std::net::IpAddr>().is_err())
923 && let Some(ref url) = target.url
924 {
925 match bootstrap_doh_host(url, target.timeout).await {
926 Ok(ip) => target.host = Some(ip),
927 Err(status) => return finish(status, Vec::new()),
928 }
929 }
930
931 let resolver = match build_resolver(&target) {
932 Ok(r) => r,
933 Err(kind) => return finish(QueryStatus::from(kind), Vec::new()),
934 };
935 let (status, records) = lookup_all(&resolver, domain, record_types, plan.transport).await;
936 finish(status, records)
937}
938
939async fn bootstrap_doh_host(
944 url: &str,
945 timeout: Duration,
946) -> std::result::Result<String, QueryStatus> {
947 let host = extract_doh_host(url).ok_or(QueryStatus::MalformedResponse)?;
948 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
949 return Ok(ip.to_string());
950 }
951 let resolver = build_system_resolver(timeout)?;
952 let lookup = resolver.lookup_ip(host).await.map_err(|e| {
953 QueryStatus::from(classify_hickory_error(
954 ValidationTransport::Doh,
955 &e.to_string(),
956 ))
957 })?;
958 let ips: Vec<std::net::IpAddr> = lookup.iter().collect();
962 ips.iter()
963 .find(|ip| ip.is_ipv4())
964 .or_else(|| ips.first())
965 .map(|ip| ip.to_string())
966 .ok_or(QueryStatus::NxDomain)
967}
968
969fn extract_doh_host(url: &str) -> Option<&str> {
970 let after_scheme = url.strip_prefix("https://").unwrap_or(url);
971 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
972 let authority = authority
973 .rsplit_once('@')
974 .map_or(authority, |(_, host_port)| host_port);
975 let host = if let Some(stripped) = authority.strip_prefix('[') {
976 stripped.split_once(']').map_or(authority, |(host, _)| host)
977 } else {
978 authority
979 .split_once(':')
980 .map_or(authority, |(host, _)| host)
981 };
982 if host.is_empty() { None } else { Some(host) }
983}
984
985fn build_system_resolver(
986 timeout: Duration,
987) -> std::result::Result<Resolver<TokioRuntimeProvider>, QueryStatus> {
988 let mut opts = ResolverOpts::default();
989 opts.timeout = timeout;
990 opts.attempts = 1;
991 let builder = Resolver::builder_tokio().map_err(|e| {
992 tracing::debug!(%e, "could not load system resolver");
993 QueryStatus::MalformedResponse
994 })?;
995 builder.with_options(opts).build().map_err(|e| {
996 tracing::debug!(%e, "system resolver build failed");
997 QueryStatus::MalformedResponse
998 })
999}
1000
1001async fn lookup_all(
1002 resolver: &Resolver<TokioRuntimeProvider>,
1003 domain: &str,
1004 record_types: &[String],
1005 transport: ValidationTransport,
1006) -> (QueryStatus, Vec<ObservedRecord>) {
1007 let mut all_records = Vec::new();
1008 let mut worst_status = QueryStatus::NoError;
1009
1010 for rr_name in record_types {
1011 let Ok(rr_type) = rr_name.parse::<RecordType>() else {
1012 worst_status = worst(worst_status, QueryStatus::MalformedResponse);
1013 continue;
1014 };
1015 match resolver.lookup(domain, rr_type).await {
1016 Ok(lookup) => {
1017 if lookup.answers().is_empty() {
1018 } else {
1021 for record in observed_records_from_answers(lookup.answers()) {
1022 push_observed_record_once(&mut all_records, record);
1023 }
1024 }
1025 }
1026 Err(err) => {
1027 let kind = classify_hickory_error(transport, &err.to_string());
1028 worst_status = worst(worst_status, QueryStatus::from(kind));
1029 }
1030 }
1031 }
1032
1033 if all_records.is_empty() {
1034 (worst_status, all_records)
1035 } else {
1036 (QueryStatus::NoError, all_records)
1040 }
1041}
1042
1043fn push_observed_record_once(records: &mut Vec<ObservedRecord>, record: ObservedRecord) {
1044 if !records.iter().any(|existing| {
1045 existing.name == record.name
1046 && existing.record_type == record.record_type
1047 && existing.ttl == record.ttl
1048 && existing.values == record.values
1049 }) {
1050 records.push(record);
1051 }
1052}
1053
1054fn observed_records_from_answers(answers: &[Record]) -> Vec<ObservedRecord> {
1055 answers
1056 .iter()
1057 .map(|record| ObservedRecord {
1058 name: record.name.to_string(),
1059 record_type: record.record_type().to_string(),
1060 ttl: Some(record.ttl),
1061 values: vec![record.data.to_string()],
1062 })
1063 .collect()
1064}
1065
1066fn worst(a: QueryStatus, b: QueryStatus) -> QueryStatus {
1067 if a.severity() >= b.severity() { a } else { b }
1068}
1069
1070fn exit_code_for(blocks: &[QueryResultBlock]) -> i32 {
1071 let mut worst = 0u8;
1072 for b in blocks {
1073 worst = worst.max(b.status.severity());
1074 }
1075 match worst {
1076 0 => 0,
1077 1 => 0, 2 => 1, _ => 2,
1080 }
1081}
1082
1083fn print_table(blocks: &[QueryResultBlock], asked_types: &[String]) {
1086 let multi_type = asked_types.len() > 1;
1087 let mut first = true;
1088 for block in blocks {
1089 if !first {
1090 println!();
1091 }
1092 first = false;
1093 print_header(block);
1094 println!();
1095 let rows = expand_rows(block, multi_type);
1096 print_rows(&rows, multi_type);
1097 }
1098}
1099
1100fn print_header(block: &QueryResultBlock) {
1101 let mut line = format!(
1102 "@ {} {}",
1103 block.target_label,
1104 transport_word(block.transport)
1105 );
1106 for (k, v) in &block.extras {
1107 if v.is_empty() {
1108 line.push_str(" ");
1109 line.push_str(k);
1110 } else {
1111 let _ = write!(&mut line, " {k}={v}");
1112 }
1113 }
1114 let _ = write!(&mut line, " {}ms", block.elapsed.as_millis());
1115 println!("{line}");
1116}
1117
1118#[derive(Debug)]
1119struct Row {
1120 name: String,
1121 rr_type: String,
1122 ttl: Option<String>,
1123 data: String,
1124}
1125
1126fn expand_rows(block: &QueryResultBlock, _multi_type: bool) -> Vec<Row> {
1127 let mut rows = Vec::new();
1132 if let Some(status_word) = block.status.header_word() {
1133 let name = trim_trailing_dot(&block.queried_name).to_string();
1134 for rr_type in &block.asked_types {
1135 rows.push(Row {
1136 name: name.clone(),
1137 rr_type: rr_type.clone(),
1138 ttl: None,
1139 data: status_word.to_string(),
1140 });
1141 }
1142 return rows;
1143 }
1144 for record in &block.records {
1145 for value in &record.values {
1146 rows.push(Row {
1147 name: trim_trailing_dot(&record.name).to_string(),
1148 rr_type: record.record_type.clone(),
1149 ttl: record.ttl.map(|ttl| ttl.to_string()),
1150 data: value.clone(),
1151 });
1152 }
1153 }
1154 rows
1155}
1156
1157fn trim_trailing_dot(name: &str) -> &str {
1158 name.strip_suffix('.').unwrap_or(name)
1159}
1160
1161fn print_rows(rows: &[Row], multi_type: bool) {
1162 if rows.is_empty() {
1163 return;
1164 }
1165 let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
1166 let type_w = rows.iter().map(|r| r.rr_type.len()).max().unwrap_or(0);
1167 let ttl_w = rows
1168 .iter()
1169 .map(|r| r.ttl.as_deref().unwrap_or("").len())
1170 .max()
1171 .unwrap_or(0);
1172
1173 for row in rows {
1174 let mut line = String::new();
1175 let _ = write!(&mut line, "{:<name_w$}", row.name);
1176 if multi_type
1177 || ttl_w > 0
1178 || rows.iter().any(|r| r.ttl.is_some())
1179 || !row.rr_type.is_empty()
1180 {
1181 let _ = write!(&mut line, " {:<type_w$}", row.rr_type);
1182 }
1183 if let Some(ttl) = &row.ttl {
1184 let _ = write!(&mut line, " {:<ttl_w$}", ttl);
1185 }
1186 let _ = write!(&mut line, " {}", row.data);
1187 println!("{line}");
1188 }
1189}
1190
1191fn print_short(blocks: &[QueryResultBlock]) {
1192 for block in blocks {
1193 for record in &block.records {
1194 for value in &record.values {
1195 println!("{value}");
1196 }
1197 }
1198 }
1199}
1200
1201#[derive(Serialize)]
1202struct JsonOutput<'a> {
1203 query: JsonQuery<'a>,
1204 target: JsonTarget<'a>,
1205 results: Vec<JsonResult<'a>>,
1206}
1207
1208#[derive(Serialize)]
1209struct JsonQuery<'a> {
1210 name: &'a str,
1211 types: &'a [String],
1212}
1213
1214#[derive(Serialize)]
1215struct JsonTarget<'a> {
1216 kind: &'a str,
1217 #[serde(skip_serializing_if = "Option::is_none")]
1218 server: Option<&'a str>,
1219 #[serde(skip_serializing_if = "Option::is_none")]
1220 cluster: Option<&'a str>,
1221 #[serde(skip_serializing_if = "Option::is_none")]
1222 system_resolver: Option<&'a str>,
1223}
1224
1225#[derive(Serialize)]
1226struct JsonResult<'a> {
1227 resolver: JsonResolver<'a>,
1228 elapsed_ms: u128,
1229 status: &'a str,
1230 #[serde(skip_serializing_if = "Option::is_none")]
1231 skip_reason: Option<&'a str>,
1232 answers: Vec<JsonAnswer>,
1233}
1234
1235#[derive(Serialize)]
1236struct JsonResolver<'a> {
1237 transport: &'a str,
1238 #[serde(skip_serializing_if = "Option::is_none")]
1239 address: Option<&'a str>,
1240 #[serde(skip_serializing_if = "Option::is_none")]
1241 port: Option<u16>,
1242 #[serde(skip_serializing_if = "Option::is_none")]
1243 url: Option<&'a str>,
1244 #[serde(skip_serializing_if = "Option::is_none")]
1245 server_name: Option<&'a str>,
1246}
1247
1248#[derive(Serialize)]
1249struct JsonAnswer {
1250 name: String,
1251 #[serde(rename = "type")]
1252 rr_type: String,
1253 data: String,
1254 #[serde(skip_serializing_if = "Option::is_none")]
1255 ttl: Option<u32>,
1256}
1257
1258fn print_json(
1259 domain: &str,
1260 record_types: &[String],
1261 kind: &TargetKind,
1262 blocks: &[QueryResultBlock],
1263) {
1264 let value = build_json_value(domain, record_types, kind, blocks);
1265 println!(
1266 "{}",
1267 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
1268 );
1269}
1270
1271fn build_json_value(
1275 domain: &str,
1276 record_types: &[String],
1277 kind: &TargetKind,
1278 blocks: &[QueryResultBlock],
1279) -> serde_json::Value {
1280 let target = match kind {
1281 TargetKind::System { display } => JsonTarget {
1282 kind: "system",
1283 server: None,
1284 cluster: None,
1285 system_resolver: Some(display.as_str()),
1286 },
1287 TargetKind::Named { server_id, cluster } => JsonTarget {
1288 kind: "named",
1289 server: Some(server_id.as_str()),
1290 cluster: cluster.as_deref(),
1291 system_resolver: None,
1292 },
1293 TargetKind::AdHoc => JsonTarget {
1294 kind: "ad_hoc",
1295 server: None,
1296 cluster: None,
1297 system_resolver: None,
1298 },
1299 };
1300
1301 let results: Vec<JsonResult> = blocks
1302 .iter()
1303 .map(|b| JsonResult {
1304 resolver: JsonResolver {
1305 transport: transport_word(b.transport),
1306 address: b.host_for_json.as_deref(),
1307 port: b.port_for_json,
1308 url: b.url.as_deref(),
1309 server_name: b
1310 .extras
1311 .iter()
1312 .find(|(k, _)| k == "sni")
1313 .map(|(_, v)| v.as_str()),
1314 },
1315 elapsed_ms: b.elapsed.as_millis(),
1316 status: b.status.json_tag(),
1317 skip_reason: match &b.status {
1318 QueryStatus::Skipped { reason } => Some(reason.as_str()),
1319 _ => None,
1320 },
1321 answers: b
1322 .records
1323 .iter()
1324 .flat_map(|r| {
1325 r.values.iter().map(move |v| JsonAnswer {
1326 name: trim_trailing_dot(&r.name).to_string(),
1327 rr_type: r.record_type.clone(),
1328 data: v.clone(),
1329 ttl: r.ttl,
1330 })
1331 })
1332 .collect(),
1333 })
1334 .collect();
1335
1336 let out = JsonOutput {
1337 query: JsonQuery {
1338 name: domain,
1339 types: record_types,
1340 },
1341 target,
1342 results,
1343 };
1344 json!(out)
1345}
1346
1347#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use crate::cli::{Cli, Command};
1353 use clap::Parser;
1354 use hickory_resolver::proto::rr::{Name, RData, Record};
1355 use rstest::rstest;
1356 use std::str::FromStr;
1357
1358 fn parse(args: &[&str]) -> Result<QueryArgs> {
1359 let mut argv = vec!["dns", "query"];
1360 argv.extend_from_slice(args);
1361 let cli = Cli::try_parse_from(argv).map_err(|e| Error::parse(e.to_string()))?;
1362 match cli.command {
1363 Command::Query(q) => Ok(q),
1364 _ => Err(Error::parse("expected Command::Query")),
1365 }
1366 }
1367
1368 #[test]
1369 fn split_targets_domain_only() {
1370 let (domain, at) = split_targets(&["huly.hankin.io".to_string()]).unwrap();
1371 assert_eq!(domain, "huly.hankin.io");
1372 assert_eq!(at, None);
1373 }
1374
1375 #[test]
1376 fn split_targets_with_at_sugar() {
1377 let (domain, at) =
1378 split_targets(&["huly.hankin.io".to_string(), "@1.1.1.1".to_string()]).unwrap();
1379 assert_eq!(domain, "huly.hankin.io");
1380 assert_eq!(at.as_deref(), Some("1.1.1.1"));
1381 }
1382
1383 #[test]
1384 fn split_targets_at_before_domain() {
1385 let (domain, at) =
1386 split_targets(&["@1.1.1.1".to_string(), "huly.hankin.io".to_string()]).unwrap();
1387 assert_eq!(domain, "huly.hankin.io");
1388 assert_eq!(at.as_deref(), Some("1.1.1.1"));
1389 }
1390
1391 #[test]
1392 fn split_targets_rejects_multiple_at() {
1393 assert!(
1394 split_targets(&[
1395 "huly.hankin.io".to_string(),
1396 "@1.1.1.1".to_string(),
1397 "@8.8.8.8".to_string(),
1398 ])
1399 .is_err()
1400 );
1401 }
1402
1403 #[test]
1404 fn split_targets_rejects_extra_positional() {
1405 assert!(
1406 split_targets(&["huly.hankin.io".to_string(), "extra.example".to_string(),]).is_err()
1407 );
1408 }
1409
1410 #[test]
1411 fn split_targets_requires_domain() {
1412 assert!(split_targets(&[]).is_err());
1413 assert!(split_targets(&["@1.1.1.1".to_string()]).is_err());
1414 }
1415
1416 #[test]
1417 fn parse_record_types_default_to_supported_standard_types() {
1418 let types = parse_record_types(&[]).unwrap();
1419 assert_eq!(
1420 types,
1421 DEFAULT_RECORD_TYPES
1422 .iter()
1423 .map(|rr_type| (*rr_type).to_string())
1424 .collect::<Vec<_>>()
1425 );
1426 }
1427
1428 #[test]
1429 fn parse_record_types_uppercases_and_dedups() {
1430 let types =
1431 parse_record_types(&["a".to_string(), "AAAA".to_string(), "A".to_string()]).unwrap();
1432 assert_eq!(types, vec!["A".to_string(), "AAAA".to_string()]);
1433 }
1434
1435 #[test]
1436 fn parse_record_types_rejects_unknown() {
1437 assert!(parse_record_types(&["BOGUS".to_string()]).is_err());
1438 }
1439
1440 #[test]
1441 fn validate_rejects_server_and_at() {
1442 let mut args = QueryArgs::default();
1443 args.server = Some("dns1".to_string());
1444 args.at = Some("1.1.1.1".to_string());
1445 assert!(validate_cli_rules(&args).is_err());
1446 }
1447
1448 #[test]
1449 fn validate_rejects_all_with_explicit_transport() {
1450 let mut args = QueryArgs::default();
1451 args.server = Some("dns1".to_string());
1452 args.all = true;
1453 args.dot = true;
1454 assert!(validate_cli_rules(&args).is_err());
1455 }
1456
1457 #[test]
1458 fn validate_rejects_all_without_server() {
1459 let mut args = QueryArgs::default();
1460 args.all = true;
1461 args.at = Some("1.1.1.1".to_string());
1462 assert!(validate_cli_rules(&args).is_err());
1463 }
1464
1465 #[test]
1466 fn validate_rejects_transport_flags_with_no_target() {
1467 let mut args = QueryArgs::default();
1468 args.dot = true;
1469 assert!(validate_cli_rules(&args).is_err());
1470 }
1471
1472 #[test]
1473 fn validate_rejects_multiple_transport_flags_with_at() {
1474 let mut args = QueryArgs::default();
1475 args.at = Some("1.1.1.1".to_string());
1476 args.dns = true;
1477 args.dot = true;
1478 assert!(validate_cli_rules(&args).is_err());
1479 }
1480
1481 #[test]
1482 fn validate_rejects_port_with_named_server() {
1483 let mut args = QueryArgs::default();
1484 args.server = Some("dns1".to_string());
1485 args.port = Some(53);
1486 assert!(validate_cli_rules(&args).is_err());
1487 }
1488
1489 #[test]
1490 fn validate_accepts_single_target_with_no_transport_flags() {
1491 let mut args = QueryArgs::default();
1492 args.server = Some("dns1".to_string());
1493 validate_cli_rules(&args).unwrap();
1494
1495 let mut args = QueryArgs::default();
1496 args.at = Some("1.1.1.1".to_string());
1497 validate_cli_rules(&args).unwrap();
1498 }
1499
1500 #[test]
1501 fn parse_ad_hoc_plain_ip_no_scheme() {
1502 let p = parse_ad_hoc("1.1.1.1").unwrap();
1503 assert_eq!(p.transport, None);
1504 assert_eq!(p.host.as_deref(), Some("1.1.1.1"));
1505 assert_eq!(p.port, None);
1506 }
1507
1508 #[test]
1509 fn parse_ad_hoc_ip_with_port() {
1510 let p = parse_ad_hoc("9.9.9.9:53").unwrap();
1511 assert_eq!(p.host.as_deref(), Some("9.9.9.9"));
1512 assert_eq!(p.port, Some(53));
1513 }
1514
1515 #[test]
1516 fn parse_ad_hoc_tls_scheme_maps_to_dot() {
1517 let p = parse_ad_hoc("tls://9.9.9.9").unwrap();
1518 assert_eq!(p.transport, Some(ValidationTransport::Dot));
1519 assert_eq!(p.host.as_deref(), Some("9.9.9.9"));
1520 }
1521
1522 #[test]
1523 fn parse_ad_hoc_https_scheme_carries_url() {
1524 let p = parse_ad_hoc("https://cloudflare-dns.com/dns-query").unwrap();
1525 assert_eq!(p.transport, Some(ValidationTransport::Doh));
1526 assert_eq!(
1527 p.url.as_deref(),
1528 Some("https://cloudflare-dns.com/dns-query")
1529 );
1530 }
1531
1532 #[test]
1533 fn parse_ad_hoc_doq_scheme() {
1534 let p = parse_ad_hoc("doq://dns.adguard.com:853").unwrap();
1535 assert_eq!(p.transport, Some(ValidationTransport::Doq));
1536 assert_eq!(p.host.as_deref(), Some("dns.adguard.com"));
1537 assert_eq!(p.port, Some(853));
1538 }
1539
1540 #[test]
1541 fn parse_ad_hoc_rejects_unknown_scheme() {
1542 assert!(parse_ad_hoc("ftp://1.1.1.1").is_err());
1543 }
1544
1545 #[test]
1546 fn parse_ad_hoc_ipv6_literal_no_port() {
1547 let p = parse_ad_hoc("[2001:db8::1]").unwrap();
1548 assert_eq!(p.host.as_deref(), Some("2001:db8::1"));
1549 assert_eq!(p.port, None);
1550 }
1551
1552 #[test]
1553 fn parse_ad_hoc_ipv6_literal_with_port() {
1554 let p = parse_ad_hoc("[2001:db8::1]:53").unwrap();
1555 assert_eq!(p.host.as_deref(), Some("2001:db8::1"));
1556 assert_eq!(p.port, Some(53));
1557 }
1558
1559 #[test]
1560 fn clap_parses_query_alias_q() {
1561 let args = parse(&["huly.hankin.io"]).unwrap();
1562 assert_eq!(args.targets, vec!["huly.hankin.io".to_string()]);
1563 }
1564
1565 #[test]
1566 fn clap_parses_at_sugar_as_positional() {
1567 let args = parse(&["huly.hankin.io", "@1.1.1.1"]).unwrap();
1568 assert_eq!(args.targets.len(), 2);
1569 assert!(args.targets.contains(&"@1.1.1.1".to_string()));
1570 }
1571
1572 #[test]
1573 fn clap_parses_multiple_transport_flags() {
1574 let args = parse(&["huly.hankin.io", "--server", "dns1", "--dot", "--doh"]).unwrap();
1575 assert!(args.dot);
1576 assert!(args.doh);
1577 assert!(!args.dns);
1578 assert!(!args.all);
1579 assert_eq!(args.server.as_deref(), Some("dns1"));
1580 }
1581
1582 #[test]
1583 fn clap_q_alias_works() {
1584 let cli = Cli::try_parse_from(["dns", "q", "huly.hankin.io"]).unwrap();
1585 match cli.command {
1586 Command::Query(q) => assert_eq!(q.targets, vec!["huly.hankin.io".to_string()]),
1587 _ => panic!("expected Command::Query"),
1588 }
1589 }
1590
1591 #[test]
1592 fn forced_transport_picks_in_precedence_order() {
1593 let mut args = QueryArgs::default();
1594 args.doh = true;
1595 assert_eq!(
1596 forced_transport_from_flags(&args),
1597 Some(ValidationTransport::Doh)
1598 );
1599 let mut args = QueryArgs::default();
1600 args.doq = true;
1601 assert_eq!(
1602 forced_transport_from_flags(&args),
1603 Some(ValidationTransport::Doq)
1604 );
1605 let args = QueryArgs::default();
1606 assert_eq!(forced_transport_from_flags(&args), None);
1607 }
1608
1609 #[test]
1610 fn worst_status_picks_higher_severity() {
1611 assert_eq!(
1612 worst(QueryStatus::NoError, QueryStatus::NxDomain),
1613 QueryStatus::NxDomain
1614 );
1615 assert_eq!(
1616 worst(QueryStatus::NxDomain, QueryStatus::NoError),
1617 QueryStatus::NxDomain
1618 );
1619 assert_eq!(
1620 worst(QueryStatus::Timeout, QueryStatus::NxDomain),
1621 QueryStatus::Timeout
1622 );
1623 }
1624
1625 #[test]
1626 fn exit_code_worst_across_blocks() {
1627 fn block(status: QueryStatus) -> QueryResultBlock {
1628 QueryResultBlock {
1629 target_label: String::new(),
1630 transport: ValidationTransport::Dns,
1631 extras: Vec::new(),
1632 url: None,
1633 host_for_json: None,
1634 port_for_json: None,
1635 elapsed: Duration::ZERO,
1636 status,
1637 records: Vec::new(),
1638 asked_types: vec!["A".to_string()],
1639 queried_name: "example.com".to_string(),
1640 }
1641 }
1642 assert_eq!(exit_code_for(&[block(QueryStatus::NoError)]), 0);
1643 assert_eq!(
1644 exit_code_for(&[block(QueryStatus::NoError), block(QueryStatus::NxDomain)]),
1645 1
1646 );
1647 assert_eq!(
1648 exit_code_for(&[block(QueryStatus::NoError), block(QueryStatus::Timeout)]),
1649 2
1650 );
1651 assert_eq!(
1653 exit_code_for(&[
1654 block(QueryStatus::NoError),
1655 block(QueryStatus::Skipped {
1656 reason: "block not configured or disabled".to_string()
1657 })
1658 ]),
1659 0
1660 );
1661 }
1662
1663 #[rstest]
1664 #[case("A", "192.0.2.10", "192.0.2.10")]
1665 #[case("AAAA", "2001:db8::10", "2001:db8::10")]
1666 #[case("CNAME", "target.example.com.", "target.example.com.")]
1667 #[case("MX", "10 mail.example.com.", "10 mail.example.com.")]
1668 #[case("TXT", "\"v=spf1 -all\"", "v=spf1 -all")]
1669 #[case("NS", "ns1.example.com.", "ns1.example.com.")]
1670 #[case("SRV", "10 20 5060 sip.example.com.", "10 20 5060 sip.example.com.")]
1671 #[case("CAA", "0 issue \"letsencrypt.org\"", "0 issue \"letsencrypt.org\"")]
1672 #[case("PTR", "host.example.com.", "host.example.com.")]
1673 #[case(
1674 "SOA",
1675 "ns1.example.com. hostmaster.example.com. 2026052901 3600 900 604800 300",
1676 "ns1.example.com. hostmaster.example.com. 2026052901 3600 900 604800 300"
1677 )]
1678 fn observed_records_preserve_actual_type_name_ttl_and_value(
1679 #[case] rr_type: &str,
1680 #[case] rdata_text: &str,
1681 #[case] expected_value: &str,
1682 ) {
1683 let rr_type = rr_type.parse::<RecordType>().unwrap();
1684 let record = test_record("owner.example.com.", 600, rr_type, rdata_text);
1685
1686 let observed = observed_records_from_answers(&[record]);
1687
1688 assert_eq!(observed.len(), 1);
1689 assert_eq!(observed[0].name, "owner.example.com.");
1690 assert_eq!(observed[0].record_type, rr_type.to_string());
1691 assert_eq!(observed[0].ttl, Some(600));
1692 assert_eq!(observed[0].values, vec![expected_value.to_string()]);
1693 }
1694
1695 #[test]
1696 fn observed_records_keep_cname_type_returned_during_aaaa_lookup() {
1697 let records = vec![
1698 test_record(
1699 "alias.example.com.",
1700 300,
1701 RecordType::CNAME,
1702 "target.example.com.",
1703 ),
1704 test_record("target.example.com.", 300, RecordType::AAAA, "2001:db8::10"),
1705 ];
1706
1707 let observed = observed_records_from_answers(&records);
1708
1709 assert_eq!(observed[0].name, "alias.example.com.");
1710 assert_eq!(observed[0].record_type, "CNAME");
1711 assert_eq!(observed[0].values, vec!["target.example.com.".to_string()]);
1712 assert_eq!(observed[1].name, "target.example.com.");
1713 assert_eq!(observed[1].record_type, "AAAA");
1714 assert_eq!(observed[1].values, vec!["2001:db8::10".to_string()]);
1715 }
1716
1717 #[test]
1718 fn observed_records_keep_cname_type_returned_during_a_lookup() {
1719 let records = vec![
1720 test_record(
1721 "alias.example.com.",
1722 300,
1723 RecordType::CNAME,
1724 "target.example.com.",
1725 ),
1726 test_record("target.example.com.", 300, RecordType::A, "192.0.2.10"),
1727 ];
1728
1729 let observed = observed_records_from_answers(&records);
1730
1731 assert_eq!(observed[0].name, "alias.example.com.");
1732 assert_eq!(observed[0].record_type, "CNAME");
1733 assert_eq!(observed[0].values, vec!["target.example.com.".to_string()]);
1734 assert_eq!(observed[1].name, "target.example.com.");
1735 assert_eq!(observed[1].record_type, "A");
1736 assert_eq!(observed[1].values, vec!["192.0.2.10".to_string()]);
1737 }
1738 #[test]
1739 fn push_observed_record_once_deduplicates_cname_seen_from_multiple_type_lookups() {
1740 let mut records = Vec::new();
1741 let cname = ObservedRecord {
1742 name: "alias.example.com.".to_string(),
1743 record_type: "CNAME".to_string(),
1744 ttl: Some(300),
1745 values: vec!["target.example.com.".to_string()],
1746 };
1747
1748 push_observed_record_once(&mut records, cname.clone());
1749 push_observed_record_once(&mut records, cname);
1750
1751 assert_eq!(records.len(), 1);
1752 assert_eq!(records[0].record_type, "CNAME");
1753 }
1754
1755 fn test_record(name: &str, ttl: u32, rr_type: RecordType, rdata_text: &str) -> Record {
1756 Record::from_rdata(
1757 Name::from_str(name).unwrap(),
1758 ttl,
1759 RData::try_from_str(rr_type, rdata_text).unwrap(),
1760 )
1761 }
1762}