1use crate::compiler::prelude::*;
11
12#[cfg(not(target_arch = "wasm32"))]
13mod non_wasm {
14 use std::collections::BTreeMap;
15 use std::net::ToSocketAddrs;
16 use std::time::Duration;
17
18 use domain::base::iana::Class;
19 use domain::base::{Name, RecordSection, Rtype};
20 use domain::rdata::AllRecordData;
21 use domain::resolv::StubResolver;
22 use domain::resolv::stub::Answer;
23 use domain::resolv::stub::conf::{ResolvConf, ResolvOptions, ServerConf, Transport};
24 use tokio::runtime::Handle;
25
26 use crate::compiler::prelude::*;
27 use crate::value::Value;
28
29 fn dns_lookup(value: &Value, qtype: &Value, qclass: &Value, options: Value) -> Resolved {
30 let host: Name<Vec<_>> = value
31 .try_bytes_utf8_lossy()?
32 .to_string()
33 .parse()
34 .map_err(|err| format!("parsing host name failed: {err}"))?;
35 let qtype: Rtype = qtype
36 .try_bytes_utf8_lossy()?
37 .to_string()
38 .parse()
39 .map_err(|err| format!("parsing query type failed: {err}"))?;
40 let qclass: Class = qclass
41 .try_bytes_utf8_lossy()?
42 .to_string()
43 .parse()
44 .map_err(|err| format!("parsing query class failed: {err}"))?;
45
46 let map = options.try_object()?;
47 let conf = build_options(&map)?;
48 let answer = tokio::task::block_in_place(|| {
49 match Handle::try_current() {
50 Ok(handle) => {
51 handle.block_on(StubResolver::from_conf(conf).query((host, qtype, qclass)))
52 }
53 Err(_) => StubResolver::run_with_conf(conf, move |stub| async move {
54 stub.query((host, qtype, qclass)).await
55 }),
56 }
57 .map_err(|err| format!("query failed: {err}"))
58 })?;
59
60 Ok(parse_answer(&answer)?.into())
61 }
62
63 #[derive(Debug, Clone)]
64 pub(super) struct DnsLookupFn {
65 pub(super) value: Box<dyn Expression>,
66 pub(super) qtype: Option<Box<dyn Expression>>,
67 pub(super) class: Option<Box<dyn Expression>>,
68 pub(super) options: Option<Box<dyn Expression>>,
69 }
70
71 fn build_options(options: &ObjectMap) -> Result<ResolvConf, ExpressionError> {
72 let mut resolv_options = ResolvOptions::default();
73
74 macro_rules! read_bool_opt {
75 ($name:ident, $resolv_name:ident) => {
76 if let Some($name) = options
77 .get(stringify!($name))
78 .map(|v| v.clone().try_boolean())
79 .transpose()?
80 {
81 resolv_options.$resolv_name = $name;
82 }
83 };
84 ($name:ident) => {
85 read_bool_opt!($name, $name);
86 };
87 }
88
89 macro_rules! read_int_opt {
90 ($name:ident, $resolv_name:ident) => {
91 if let Some($name) = options
92 .get(stringify!($name))
93 .map(|v| v.clone().try_integer())
94 .transpose()?
95 {
96 resolv_options.$resolv_name = $name.try_into().map_err(|err| {
97 format!(
98 "{} has to be a positive integer, got: {}. ({})",
99 stringify!($resolv_name),
100 $name,
101 err
102 )
103 })?;
104 }
105 };
106 ($name:ident) => {
107 read_int_opt!($name, $name);
108 };
109 }
110
111 read_int_opt!(ndots);
112 read_int_opt!(attempts);
113 read_bool_opt!(aa_only);
114 read_bool_opt!(tcp, use_vc);
115 read_bool_opt!(recurse);
116 read_bool_opt!(rotate);
117
118 if let Some(timeout) = options
119 .get("timeout")
120 .map(|v| v.clone().try_integer())
121 .transpose()?
122 {
123 resolv_options.timeout = Duration::from_secs(timeout.try_into().map_err(|err| {
124 format!("timeout has to be a positive integer, got: {timeout}. ({err})")
125 })?);
126 }
127
128 let mut conf = ResolvConf {
129 options: resolv_options,
130 ..Default::default()
131 };
132
133 if let Some(servers) = options
134 .get("servers")
135 .map(|s| s.clone().try_array())
136 .transpose()?
137 {
138 conf.servers.clear();
139 for server in servers {
140 let mut server = server.try_bytes_utf8_lossy()?;
141 if !server.contains(':') {
142 server += ":53";
143 }
144 for addr in server
145 .to_socket_addrs()
146 .map_err(|err| format!("can't resolve nameserver ({server}): {err}"))?
147 {
148 conf.servers.push(ServerConf::new(addr, Transport::UdpTcp));
149 conf.servers.push(ServerConf::new(addr, Transport::Tcp));
150 }
151 }
152 }
153
154 conf.finalize();
155 Ok(conf)
156 }
157
158 fn parse_answer(answer: &Answer) -> Result<ObjectMap, ExpressionError> {
159 let mut result = ObjectMap::new();
160 let header_section = answer.header();
161 let rcode = header_section.rcode();
162 result.insert("fullRcode".into(), rcode.to_int().into());
163 result.insert("rcodeName".into(), rcode.to_string().into());
164 let header = {
165 let mut header_obj = ObjectMap::new();
166 let counts = answer.header_counts();
167 header_obj.insert("aa".into(), header_section.aa().into());
168 header_obj.insert("ad".into(), header_section.ad().into());
169 header_obj.insert("cd".into(), header_section.cd().into());
170 header_obj.insert("ra".into(), header_section.ra().into());
171 header_obj.insert("rd".into(), header_section.rd().into());
172 header_obj.insert("tc".into(), header_section.tc().into());
173 header_obj.insert("qr".into(), header_section.qr().into());
174 header_obj.insert("opcode".into(), header_section.opcode().to_int().into());
175 header_obj.insert("rcode".into(), header_section.rcode().to_int().into());
176 header_obj.insert("anCount".into(), counts.ancount().into());
177 header_obj.insert("arCount".into(), counts.arcount().into());
178 header_obj.insert("nsCount".into(), counts.nscount().into());
179 header_obj.insert("qdCount".into(), counts.qdcount().into());
180 header_obj
181 };
182 result.insert("header".into(), header.into());
183
184 let (question, answer_section, authority, additional) = answer
185 .sections()
186 .map_err(|err| format!("parsing response sections failed: {err}"))?;
187
188 let question = {
189 let mut questions = Vec::<ObjectMap>::new();
190 for q in question {
191 let q = q.map_err(|err| format!("parsing question section failed: {err}"))?;
192 let mut question_obj = ObjectMap::new();
193 question_obj.insert("class".into(), q.qclass().to_string().into());
194 question_obj.insert("domainName".into(), q.qname().to_string().into());
195 let qtype = q.qtype();
196 question_obj.insert("questionType".into(), qtype.to_string().into());
197 question_obj.insert("questionTypeId".into(), qtype.to_int().into());
198 questions.push(question_obj);
199 }
200 questions
201 };
202 result.insert("question".into(), question.into());
203 result.insert(
204 "answers".into(),
205 parse_record_section(answer_section)?.into(),
206 );
207 result.insert("authority".into(), parse_record_section(authority)?.into());
208 result.insert(
209 "additional".into(),
210 parse_record_section(additional)?.into(),
211 );
212
213 Ok(result)
214 }
215
216 fn parse_record_section(
217 section: RecordSection<'_, Bytes>,
218 ) -> Result<Vec<ObjectMap>, ExpressionError> {
219 let mut records = Vec::<ObjectMap>::new();
220 for r in section {
221 let r = r.map_err(|err| format!("parsing record section failed: {err}"))?;
222 let mut record_obj = ObjectMap::new();
223 record_obj.insert("class".into(), r.class().to_string().into());
224 record_obj.insert("domainName".into(), r.owner().to_string().into());
225 let rtype = r.rtype();
226 let record_data = r
227 .to_record::<AllRecordData<_, _>>()
228 .map_err(|err| format!("parsing rData failed: {err}"))?
229 .map(|r| r.data().to_string());
230 record_obj.insert("rData".into(), record_data.into());
231 record_obj.insert("recordType".into(), rtype.to_string().into());
232 record_obj.insert("recordTypeId".into(), rtype.to_int().into());
233 record_obj.insert("ttl".into(), r.ttl().as_secs().into());
234 records.push(record_obj);
235 }
236 Ok(records)
237 }
238
239 impl FunctionExpression for DnsLookupFn {
240 fn resolve(&self, ctx: &mut Context) -> Resolved {
241 let value = self.value.resolve(ctx)?;
242 let qtype = self
243 .qtype
244 .map_resolve_with_default(ctx, || super::DEFAULT_QTYPE.clone())?;
245 let class = self
246 .class
247 .map_resolve_with_default(ctx, || super::DEFAULT_CLASS.clone())?;
248 let options = self
249 .options
250 .map_resolve_with_default(ctx, || super::DEFAULT_OPTIONS.clone())?;
251 dns_lookup(&value, &qtype, &class, options)
252 }
253
254 fn type_def(&self, _: &state::TypeState) -> TypeDef {
255 TypeDef::object(inner_kind()).fallible()
256 }
257 }
258
259 fn header_kind() -> BTreeMap<Field, Kind> {
260 BTreeMap::from([
261 (Field::from("aa"), Kind::boolean()),
262 (Field::from("ad"), Kind::boolean()),
263 (Field::from("anCount"), Kind::integer()),
264 (Field::from("arCount"), Kind::integer()),
265 (Field::from("cd"), Kind::boolean()),
266 (Field::from("nsCount"), Kind::integer()),
267 (Field::from("opcode"), Kind::integer()),
268 (Field::from("qdCount"), Kind::integer()),
269 (Field::from("qr"), Kind::integer()),
270 (Field::from("ra"), Kind::boolean()),
271 (Field::from("rcode"), Kind::integer()),
272 (Field::from("rd"), Kind::boolean()),
273 (Field::from("tc"), Kind::boolean()),
274 ])
275 }
276
277 fn rdata_kind() -> BTreeMap<Field, Kind> {
278 BTreeMap::from([
279 (Field::from("class"), Kind::bytes()),
280 (Field::from("domainName"), Kind::bytes()),
281 (Field::from("rData"), Kind::bytes()),
282 (Field::from("recordType"), Kind::bytes()),
283 (Field::from("recordTypeId"), Kind::integer()),
284 (Field::from("ttl"), Kind::integer()),
285 ])
286 }
287
288 fn question_kind() -> BTreeMap<Field, Kind> {
289 BTreeMap::from([
290 (Field::from("class"), Kind::bytes()),
291 (Field::from("domainName"), Kind::bytes()),
292 (Field::from("questionType"), Kind::bytes()),
293 (Field::from("questionTypeId"), Kind::integer()),
294 ])
295 }
296
297 pub(super) fn inner_kind() -> BTreeMap<Field, Kind> {
298 BTreeMap::from([
299 (Field::from("fullRcode"), Kind::integer()),
300 (Field::from("rcodeName"), Kind::bytes() | Kind::null()),
301 (Field::from("time"), Kind::bytes() | Kind::null()),
302 (Field::from("timePrecision"), Kind::bytes() | Kind::null()),
303 (
304 Field::from("answers"),
305 Kind::array(Collection::from_unknown(Kind::object(rdata_kind()))),
306 ),
307 (
308 Field::from("authority"),
309 Kind::array(Collection::from_unknown(Kind::object(rdata_kind()))),
310 ),
311 (
312 Field::from("additional"),
313 Kind::array(Collection::from_unknown(Kind::object(rdata_kind()))),
314 ),
315 (Field::from("header"), Kind::object(header_kind())),
316 (
317 Field::from("question"),
318 Kind::array(Collection::from_unknown(Kind::object(question_kind()))),
319 ),
320 ])
321 }
322}
323
324#[allow(clippy::wildcard_imports)]
325#[cfg(not(target_arch = "wasm32"))]
326use non_wasm::*;
327
328use std::sync::LazyLock;
329
330static DEFAULT_QTYPE: LazyLock<Value> = LazyLock::new(|| Value::Bytes(Bytes::from("A")));
331static DEFAULT_CLASS: LazyLock<Value> = LazyLock::new(|| Value::Bytes(Bytes::from("IN")));
332static DEFAULT_OPTIONS: LazyLock<Value> =
333 LazyLock::new(|| Value::Object(std::collections::BTreeMap::new()));
334
335static PARAMETERS: LazyLock<Vec<Parameter>> = LazyLock::new(|| {
336 vec![
337 Parameter::required("value", kind::BYTES, "The domain name to query."),
338 Parameter::optional("qtype", kind::BYTES, "The DNS record type to query (e.g., A, AAAA, MX, TXT). Defaults to A.")
339 .default(&DEFAULT_QTYPE),
340 Parameter::optional("class", kind::BYTES, "The DNS query class. Defaults to IN (Internet).")
341 .default(&DEFAULT_CLASS),
342 Parameter::optional("options", kind::OBJECT, "DNS resolver options. Supported fields: servers (array of nameserver addresses), timeout (seconds), attempts (number of retry attempts), ndots, aa_only, tcp, recurse, rotate.")
343 .default(&DEFAULT_OPTIONS),
344 ]
345});
346
347#[derive(Clone, Copy, Debug)]
348pub struct DnsLookup;
349
350impl Function for DnsLookup {
351 fn identifier(&self) -> &'static str {
352 "dns_lookup"
353 }
354
355 fn usage(&self) -> &'static str {
356 "Performs a DNS lookup on the provided domain name."
357 }
358
359 fn notices(&self) -> &'static [&'static str] {
360 &[indoc! {"
361 This function performs network calls and blocks on each request until a response is
362 received. It is not recommended for frequent or performance-critical workflows.
363 "}]
364 }
365
366 fn category(&self) -> &'static str {
367 Category::System.as_ref()
368 }
369
370 fn return_kind(&self) -> u16 {
371 kind::OBJECT
372 }
373
374 fn parameters(&self) -> &'static [Parameter] {
375 PARAMETERS.as_slice()
376 }
377
378 #[cfg(not(feature = "test"))]
379 fn examples(&self) -> &'static [Example] {
380 &[]
381 }
382
383 #[allow(clippy::too_many_lines)]
384 #[cfg(feature = "test")]
385 fn examples(&self) -> &'static [Example] {
386 &[
387 example! {
388 title: "Basic lookup",
389 source: indoc! {r#"
390 res = dns_lookup!("dns.google")
391 # reset non-static ttl so result is static
392 res.answers = map_values(res.answers) -> |value| {
393 value.ttl = 600
394 value
395 }
396 # remove extra responses for example
397 res.answers = filter(res.answers) -> |_, value| {
398 value.rData == "8.8.8.8"
399 }
400 # remove class since this is also dynamic
401 res.additional = map_values(res.additional) -> |value| {
402 del(value.class)
403 value
404 }
405 res
406 "#},
407 result: Ok(indoc!(
408 r#"{
409 "additional": [
410 {
411 "domainName": "",
412 "rData": "OPT ...",
413 "recordType": "OPT",
414 "recordTypeId": 41,
415 "ttl": 0
416 }
417 ],
418 "answers": [
419 {
420 "class": "IN",
421 "domainName": "dns.google",
422 "rData": "8.8.8.8",
423 "recordType": "A",
424 "recordTypeId": 1,
425 "ttl": 600
426 }
427 ],
428 "authority": [],
429 "fullRcode": 0,
430 "header": {
431 "aa": false,
432 "ad": false,
433 "anCount": 2,
434 "arCount": 1,
435 "cd": false,
436 "nsCount": 0,
437 "opcode": 0,
438 "qdCount": 1,
439 "qr": true,
440 "ra": true,
441 "rcode": 0,
442 "rd": true,
443 "tc": false
444 },
445 "question": [
446 {
447 "class": "IN",
448 "domainName": "dns.google",
449 "questionType": "A",
450 "questionTypeId": 1
451 }
452 ],
453 "rcodeName": "NOERROR"
454 }"#
455 )),
456 },
457 example! {
458 title: "Custom class and qtype",
459 source: indoc! {r#"
460 res = dns_lookup!("dns.google", class: "IN", qtype: "A")
461 # reset non-static ttl so result is static
462 res.answers = map_values(res.answers) -> |value| {
463 value.ttl = 600
464 value
465 }
466 # remove extra responses for example
467 res.answers = filter(res.answers) -> |_, value| {
468 value.rData == "8.8.8.8"
469 }
470 # remove class since this is also dynamic
471 res.additional = map_values(res.additional) -> |value| {
472 del(value.class)
473 value
474 }
475 res
476 "#},
477 result: Ok(indoc!(
478 r#"{
479 "additional": [
480 {
481 "domainName": "",
482 "rData": "OPT ...",
483 "recordType": "OPT",
484 "recordTypeId": 41,
485 "ttl": 0
486 }
487 ],
488 "answers": [
489 {
490 "class": "IN",
491 "domainName": "dns.google",
492 "rData": "8.8.8.8",
493 "recordType": "A",
494 "recordTypeId": 1,
495 "ttl": 600
496 }
497 ],
498 "authority": [],
499 "fullRcode": 0,
500 "header": {
501 "aa": false,
502 "ad": false,
503 "anCount": 2,
504 "arCount": 1,
505 "cd": false,
506 "nsCount": 0,
507 "opcode": 0,
508 "qdCount": 1,
509 "qr": true,
510 "ra": true,
511 "rcode": 0,
512 "rd": true,
513 "tc": false
514 },
515 "question": [
516 {
517 "class": "IN",
518 "domainName": "dns.google",
519 "questionType": "A",
520 "questionTypeId": 1
521 }
522 ],
523 "rcodeName": "NOERROR"
524 }"#
525 )),
526 },
527 example! {
528 title: "Custom options",
529 source: indoc! {r#"
530 res = dns_lookup!("dns.google", options: {"timeout": 30, "attempts": 5})
531 res.answers = map_values(res.answers) -> |value| {
532 value.ttl = 600
533 value
534 }
535 # remove extra responses for example
536 res.answers = filter(res.answers) -> |_, value| {
537 value.rData == "8.8.8.8"
538 }
539 # remove class since this is also dynamic
540 res.additional = map_values(res.additional) -> |value| {
541 del(value.class)
542 value
543 }
544 res
545 "#},
546 result: Ok(indoc!(
547 r#"{
548 "additional": [
549 {
550 "domainName": "",
551 "rData": "OPT ...",
552 "recordType": "OPT",
553 "recordTypeId": 41,
554 "ttl": 0
555 }
556 ],
557 "answers": [
558 {
559 "class": "IN",
560 "domainName": "dns.google",
561 "rData": "8.8.8.8",
562 "recordType": "A",
563 "recordTypeId": 1,
564 "ttl": 600
565 }
566 ],
567 "authority": [],
568 "fullRcode": 0,
569 "header": {
570 "aa": false,
571 "ad": false,
572 "anCount": 2,
573 "arCount": 1,
574 "cd": false,
575 "nsCount": 0,
576 "opcode": 0,
577 "qdCount": 1,
578 "qr": true,
579 "ra": true,
580 "rcode": 0,
581 "rd": true,
582 "tc": false
583 },
584 "question": [
585 {
586 "class": "IN",
587 "domainName": "dns.google",
588 "questionType": "A",
589 "questionTypeId": 1
590 }
591 ],
592 "rcodeName": "NOERROR"
593 }"#
594 )),
595 },
596 example! {
597 title: "Custom server",
598 source: indoc! {r#"
599 res = dns_lookup!("dns.google", options: {"servers": ["dns.quad9.net"]})
600 res.answers = map_values(res.answers) -> |value| {
601 value.ttl = 600
602 value
603 }
604 # remove extra responses for example
605 res.answers = filter(res.answers) -> |_, value| {
606 value.rData == "8.8.8.8"
607 }
608 # remove class since this is also dynamic
609 res.additional = map_values(res.additional) -> |value| {
610 del(value.class)
611 value
612 }
613 res
614 "#},
615 result: Ok(indoc!(
616 r#"{
617 "additional": [
618 {
619 "domainName": "",
620 "rData": "OPT ...",
621 "recordType": "OPT",
622 "recordTypeId": 41,
623 "ttl": 0
624 }
625 ],
626 "answers": [
627 {
628 "class": "IN",
629 "domainName": "dns.google",
630 "rData": "8.8.8.8",
631 "recordType": "A",
632 "recordTypeId": 1,
633 "ttl": 600
634 }
635 ],
636 "authority": [],
637 "fullRcode": 0,
638 "header": {
639 "aa": false,
640 "ad": false,
641 "anCount": 2,
642 "arCount": 1,
643 "cd": false,
644 "nsCount": 0,
645 "opcode": 0,
646 "qdCount": 1,
647 "qr": true,
648 "ra": true,
649 "rcode": 0,
650 "rd": true,
651 "tc": false
652 },
653 "question": [
654 {
655 "class": "IN",
656 "domainName": "dns.google",
657 "questionType": "A",
658 "questionTypeId": 1
659 }
660 ],
661 "rcodeName": "NOERROR"
662 }"#
663 )),
664 },
665 ]
666 }
667
668 #[cfg(not(target_arch = "wasm32"))]
669 fn compile(
670 &self,
671 _state: &state::TypeState,
672 _ctx: &mut FunctionCompileContext,
673 arguments: ArgumentList,
674 ) -> Compiled {
675 let value = arguments.required("value");
676 let qtype = arguments.optional("qtype");
677 let class = arguments.optional("class");
678 let options = arguments.optional("options");
679
680 Ok(DnsLookupFn {
681 value,
682 qtype,
683 class,
684 options,
685 }
686 .as_expr())
687 }
688
689 #[cfg(target_arch = "wasm32")]
690 fn compile(
691 &self,
692 _state: &state::TypeState,
693 ctx: &mut FunctionCompileContext,
694 _arguments: ArgumentList,
695 ) -> Compiled {
696 Ok(super::WasmUnsupportedFunction::new(ctx.span(), TypeDef::bytes().fallible()).as_expr())
697 }
698}
699
700#[cfg(test)]
701#[cfg(not(target_arch = "wasm32"))]
702mod tests {
703 use std::collections::{BTreeMap, HashSet};
704
705 use super::*;
706 use crate::value;
707
708 impl Default for DnsLookupFn {
709 fn default() -> Self {
710 Self {
711 value: expr!(""),
712 qtype: None,
713 class: None,
714 options: None,
715 }
716 }
717 }
718
719 #[test]
720 fn test_invalid_name() {
721 let result = execute_dns_lookup(&DnsLookupFn {
722 value: expr!("wrong.local"),
723 ..Default::default()
724 });
725
726 assert_ne!(result["fullRcode"], value!(0));
727 assert_ne!(result["rcodeName"], value!("NOERROR"));
728 assert_eq!(
729 result["question"].as_array_unwrap()[0],
730 value!({
731 "questionTypeId": 1,
732 "questionType": "A",
733 "class": "IN",
734 "domainName": "wrong.local"
735 })
736 );
737 }
738
739 #[test]
740 #[cfg(target_os = "linux")]
741 fn test_localhost() {
743 let result = execute_dns_lookup(&DnsLookupFn {
744 value: expr!("localhost"),
745 ..Default::default()
746 });
747
748 assert_eq!(result["fullRcode"], value!(0));
749 assert_eq!(result["rcodeName"], value!("NOERROR"));
750 assert_eq!(
751 result["question"].as_array_unwrap()[0],
752 value!({
753 "questionTypeId": 1,
754 "questionType": "A",
755 "class": "IN",
756 "domainName": "localhost"
757 })
758 );
759 let answer = result["answers"].as_array_unwrap()[0].as_object().unwrap();
760 assert_eq!(answer["rData"], value!("127.0.0.1"));
761 }
762
763 #[test]
764 fn test_custom_type() {
765 let result = execute_dns_lookup(&DnsLookupFn {
766 value: expr!("google.com"),
767 qtype: Some(expr!("mx")),
768 ..Default::default()
769 });
770
771 assert_eq!(result["fullRcode"], value!(0));
772 assert_eq!(result["rcodeName"], value!("NOERROR"));
773 assert_eq!(
774 result["question"].as_array_unwrap()[0],
775 value!({
776 "questionTypeId": 15,
777 "questionType": "MX",
778 "class": "IN",
779 "domainName": "google.com"
780 })
781 );
782 }
783
784 #[test]
785 fn test_google() {
786 let result = execute_dns_lookup(&DnsLookupFn {
787 value: expr!("dns.google"),
788 ..Default::default()
789 });
790
791 assert_eq!(result["fullRcode"], value!(0));
792 assert_eq!(result["rcodeName"], value!("NOERROR"));
793 assert_eq!(
794 result["question"].as_array_unwrap()[0],
795 value!({
796 "questionTypeId": 1,
797 "questionType": "A",
798 "class": "IN",
799 "domainName": "dns.google"
800 })
801 );
802 let answers: HashSet<String> = result["answers"]
803 .as_array_unwrap()
804 .iter()
805 .map(|answer| {
806 answer.as_object().unwrap()["rData"]
807 .as_str()
808 .unwrap()
809 .to_string()
810 })
811 .collect();
812 let expected: HashSet<String> = vec!["8.8.8.8".to_string(), "8.8.4.4".to_string()]
813 .into_iter()
814 .collect();
815 assert_eq!(answers, expected);
816 }
817
818 #[test]
819 fn unknown_options_ignored() {
820 let result = execute_dns_lookup(&DnsLookupFn {
821 value: expr!("dns.google"),
822 options: Some(expr!({"test": "test"})),
823 ..Default::default()
824 });
825
826 assert_eq!(result["rcodeName"], value!("NOERROR"));
827 }
828
829 #[test]
830 fn invalid_option_type() {
831 let result = execute_dns_lookup_with_expected_error(&DnsLookupFn {
832 value: expr!("dns.google"),
833 options: Some(expr!({"tcp": "yes"})),
834 ..Default::default()
835 });
836
837 assert_eq!(result.message(), "expected boolean, got string");
838 }
839
840 #[test]
841 fn negative_int_type() {
842 let attempts_val = -5;
843 let result = execute_dns_lookup_with_expected_error(&DnsLookupFn {
844 value: expr!("dns.google"),
845 options: Some(expr!({"attempts": attempts_val})),
846 ..Default::default()
847 });
848
849 assert_eq!(
850 result.message(),
851 "attempts has to be a positive integer, got: -5. (out of range integral type conversion attempted)"
852 );
853 }
854
855 fn prepare_dns_lookup(dns_lookup_fn: &DnsLookupFn) -> Resolved {
856 let tz = TimeZone::default();
857 let mut object: Value = Value::Object(BTreeMap::new());
858 let mut runtime_state = state::RuntimeState::default();
859 let mut ctx = Context::new(&mut object, &mut runtime_state, &tz);
860 dns_lookup_fn.resolve(&mut ctx)
861 }
862
863 fn execute_dns_lookup(dns_lookup_fn: &DnsLookupFn) -> ObjectMap {
864 prepare_dns_lookup(dns_lookup_fn)
865 .map_err(|e| format!("{:#}", anyhow::anyhow!(e)))
866 .unwrap()
867 .try_object()
868 .unwrap()
869 }
870
871 fn execute_dns_lookup_with_expected_error(dns_lookup_fn: &DnsLookupFn) -> ExpressionError {
872 prepare_dns_lookup(dns_lookup_fn).unwrap_err()
873 }
874}