vrl/stdlib/
dns_lookup.rs

1//! # DNS Lookup Function
2//!
3//! This function provides DNS lookup capabilities but is not recommended for frequent or performance-critical workflows.
4//! It performs network calls, relying on a single-threaded worker that blocks on each request
5//! until a response is received, which can degrade performance in high-throughput applications.
6//!
7//! Due to the potential for network-related delays or failures, avoid using this function
8//! in latency-sensitive contexts.
9
10use 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    // MacOS resolver doesn't always handle localhost
742    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}