Skip to main content

dnslib/core/dns/records/
query.rs

1use serde_json::Value;
2
3use crate::core::{
4    dns::{
5        responses::ListRecordsResponse,
6        service::{ListRecordsOptions, ZoneRead},
7    },
8    error::Result,
9};
10
11/// Build the fully-qualified domain name from a possibly-relative label and an optional zone.
12///
13/// Examples:
14/// - `("huly", Some("hankin.io"))` → `"huly.hankin.io"`
15/// - `("huly.hankin.io", Some("hankin.io"))` → `"huly.hankin.io"` (already qualified)
16/// - `("@", Some("hankin.io"))` → `"hankin.io"` (zone apex)
17/// - `("huly.hankin.io", None)` → `"huly.hankin.io"` (passed through)
18#[must_use]
19pub fn resolve_fqdn(domain: &str, zone: Option<&str>) -> String {
20    let Some(zone) = zone else {
21        return domain.trim_end_matches('.').to_string();
22    };
23    let domain = domain.trim_end_matches('.');
24    let zone = zone.trim_end_matches('.');
25    if domain == "@" {
26        return zone.to_string();
27    }
28    let d_lower = domain.to_lowercase();
29    let z_lower = zone.to_lowercase();
30    if d_lower == z_lower || d_lower.ends_with(&format!(".{z_lower}")) {
31        domain.to_string()
32    } else {
33        format!("{domain}.{zone}")
34    }
35}
36
37/// Strip the leftmost DNS label to get the likely parent zone name.
38/// Returns `None` for single-label names (e.g. `"hankin"`).
39#[must_use]
40pub fn infer_zone(fqdn: &str) -> Option<String> {
41    let fqdn = fqdn.trim_end_matches('.');
42    fqdn.find('.').map(|pos| fqdn[pos + 1..].to_string())
43}
44
45/// Extract zone/domain names from a `list_zones` response.
46/// Handles the three known vendor formats:
47/// - Technitium: `{"response": {"zones": [{"name": "..."}]}}`
48/// - Pangolin:   `{"domains": [{"baseDomain": "..."}]}`
49/// - Cloudflare: `[{"name": "..."}]` (array at root after envelope unwrap)
50#[must_use]
51pub fn extract_zone_names(value: &Value) -> Vec<String> {
52    if let Some(arr) = value
53        .get("response")
54        .and_then(|r| r.get("zones"))
55        .and_then(|z| z.as_array())
56    {
57        let names: Vec<_> = arr
58            .iter()
59            .filter_map(|z| z.get("name").and_then(|n| n.as_str()).map(str::to_string))
60            .collect();
61        if !names.is_empty() {
62            return names;
63        }
64    }
65
66    if let Some(arr) = value.get("domains").and_then(|d| d.as_array()) {
67        let names: Vec<_> = arr
68            .iter()
69            .filter_map(|d| {
70                d.get("baseDomain")
71                    .and_then(|n| n.as_str())
72                    .map(str::to_string)
73            })
74            .collect();
75        if !names.is_empty() {
76            return names;
77        }
78    }
79
80    if let Some(arr) = value.as_array() {
81        let names: Vec<_> = arr
82            .iter()
83            .filter_map(|z| z.get("name").and_then(|n| n.as_str()).map(str::to_string))
84            .collect();
85        if !names.is_empty() {
86            return names;
87        }
88    }
89
90    Vec::new()
91}
92
93/// Resolve CLI/MCP-style record-list inputs into one vendor-neutral record query.
94///
95/// # Errors
96///
97/// Returns errors from zone listing or record listing operations.
98pub async fn list_records_for_query<C: ZoneRead + Send + Sync>(
99    client: &C,
100    domain: Option<&str>,
101    zone: Option<&str>,
102    all_subdomains: bool,
103    use_local_ip: bool,
104) -> Result<ListRecordsResponse> {
105    let options = ListRecordsOptions {
106        use_local_ip,
107        all_subdomains,
108    };
109
110    let Some(domain) = domain else {
111        return list_records_for_all_zones(client, options).await;
112    };
113
114    let effective_fqdn = resolve_fqdn(domain, zone);
115    let is_bare_label = zone.is_none() && !effective_fqdn.contains('.');
116
117    if is_bare_label {
118        return search_bare_label_in_zones(client, &effective_fqdn, all_subdomains, options).await;
119    }
120
121    let (query_domain, query_zone) = if all_subdomains {
122        let zone_name = zone
123            .map(str::to_string)
124            .or_else(|| infer_zone(&effective_fqdn).filter(|z| z.contains('.')))
125            .unwrap_or_else(|| effective_fqdn.clone());
126        (zone_name.clone(), Some(zone_name))
127    } else {
128        (effective_fqdn.clone(), zone.map(str::to_string))
129    };
130
131    let mut response = client
132        .list_records(&query_domain, query_zone.as_deref(), options)
133        .await?;
134
135    if all_subdomains {
136        filter_records_by_domain(&mut response, &effective_fqdn, true);
137    }
138
139    Ok(response)
140}
141
142/// Query every hosted zone for records whose DNS name equals `label`.
143/// When `all_subdomains` is true, records beneath `label` in each zone are also included.
144/// Zones where the label does not exist are silently skipped.
145///
146/// # Errors
147///
148/// Returns an error if listing zones fails. Per-zone misses are skipped to
149/// preserve bare-label search behavior across heterogeneous vendors.
150pub async fn search_bare_label_in_zones<C: ZoneRead + Send + Sync>(
151    client: &C,
152    label: &str,
153    all_subdomains: bool,
154    options: ListRecordsOptions,
155) -> Result<ListRecordsResponse> {
156    let zones_value = client.list_zones(1, 1000).await?;
157    let zone_names = extract_zone_names(&zones_value);
158
159    let mut all_zone_records = Vec::new();
160    for zone_name in &zone_names {
161        let target_fqdn = format!("{label}.{zone_name}");
162        if all_subdomains {
163            let Ok(mut resp) = client
164                .list_records(zone_name, Some(zone_name.as_str()), options)
165                .await
166            else {
167                continue;
168            };
169            filter_records_by_domain(&mut resp, &target_fqdn, true);
170            all_zone_records.extend(resp.zones);
171        } else if let Ok(mut resp) = client
172            .list_records(&target_fqdn, Some(zone_name.as_str()), options)
173            .await
174        {
175            // Some vendors ignore the domain argument and return the full
176            // zone record set, so filter to the exact target FQDN.
177            filter_records_by_domain(&mut resp, &target_fqdn, false);
178            all_zone_records.extend(resp.zones);
179        }
180    }
181    Ok(ListRecordsResponse {
182        zones: all_zone_records,
183    })
184}
185
186/// Query every hosted zone and return its complete record set.
187///
188/// # Errors
189///
190/// Returns an error if listing zones fails or a zone record-list request fails.
191pub async fn list_records_for_all_zones<C: ZoneRead + Send + Sync>(
192    client: &C,
193    options: ListRecordsOptions,
194) -> Result<ListRecordsResponse> {
195    let zones_value = client.list_zones(1, 1000).await?;
196    let zone_names = extract_zone_names(&zones_value);
197
198    let mut all_zone_records = Vec::new();
199    for zone_name in &zone_names {
200        let resp = client
201            .list_records(zone_name, Some(zone_name.as_str()), options)
202            .await?;
203        all_zone_records.extend(resp.zones);
204    }
205
206    Ok(ListRecordsResponse {
207        zones: all_zone_records,
208    })
209}
210
211/// Retain only records whose FQDN matches `target_fqdn` (or, when `all_subdomains`
212/// is true, any record at or under `target_fqdn`). Zones that become empty are dropped.
213pub fn filter_records_by_domain(
214    response: &mut ListRecordsResponse,
215    target_fqdn: &str,
216    all_subdomains: bool,
217) {
218    let target = target_fqdn.trim_end_matches('.').to_lowercase();
219    for zone_records in &mut response.zones {
220        let zone = zone_records.zone.name.to_lowercase();
221        zone_records.records.retain(|r| {
222            let record_name = r.name.trim_end_matches('.').to_lowercase();
223            let record_fqdn = if record_name == "@" {
224                zone.clone()
225            } else if record_name == zone || record_name.ends_with(&format!(".{zone}")) {
226                record_name
227            } else {
228                format!("{record_name}.{zone}")
229            };
230            if all_subdomains {
231                record_fqdn == target || record_fqdn.ends_with(&format!(".{target}"))
232            } else {
233                record_fqdn == target
234            }
235        });
236    }
237    response.zones.retain(|z| !z.records.is_empty());
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::core::dns::responses::{ZoneInfo, ZoneRecord, ZoneRecords};
244    use rstest::{fixture, rstest};
245    use serde_json::{Value, json};
246    use std::sync::Mutex;
247
248    #[fixture]
249    fn options() -> ListRecordsOptions {
250        ListRecordsOptions::default()
251    }
252
253    #[fixture]
254    fn mixed_options() -> ListRecordsOptions {
255        ListRecordsOptions {
256            use_local_ip: true,
257            all_subdomains: true,
258        }
259    }
260
261    fn make_zone(name: &str) -> ZoneInfo {
262        ZoneInfo {
263            id: None,
264            name: name.to_string(),
265            zone_type: "Primary".to_string(),
266            disabled: false,
267            dnssec_status: None,
268        }
269    }
270
271    fn make_record(name: &str) -> ZoneRecord {
272        ZoneRecord {
273            name: name.to_string(),
274            record_type: "A".to_string(),
275            ttl: 300,
276            disabled: false,
277            comments: String::new(),
278            expiry_ttl: 0,
279            data: json!({"ipAddress": "1.2.3.4"}),
280            parsed: None,
281        }
282    }
283
284    struct FakeZoneRead {
285        zones: Value,
286        calls: Mutex<Vec<(String, Option<String>, ListRecordsOptions)>>,
287    }
288
289    impl FakeZoneRead {
290        fn new(zones: Value) -> Self {
291            Self {
292                zones,
293                calls: Mutex::new(Vec::new()),
294            }
295        }
296
297        fn calls(&self) -> Vec<(String, Option<String>, ListRecordsOptions)> {
298            self.calls
299                .lock()
300                .expect("calls mutex should not be poisoned")
301                .clone()
302        }
303    }
304
305    impl ZoneRead for FakeZoneRead {
306        async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
307            Ok(self.zones.clone())
308        }
309
310        async fn list_records<'a>(
311            &'a self,
312            domain: &'a str,
313            zone: Option<&'a str>,
314            options: ListRecordsOptions,
315        ) -> Result<ListRecordsResponse> {
316            self.calls
317                .lock()
318                .expect("calls mutex should not be poisoned")
319                .push((domain.to_string(), zone.map(str::to_string), options));
320            Ok(ListRecordsResponse::single(
321                make_zone(zone.unwrap_or(domain)),
322                vec![
323                    make_record("@"),
324                    make_record("huly"),
325                    make_record("sub.huly"),
326                ],
327            ))
328        }
329    }
330
331    #[rstest]
332    #[case::relative_label("huly", Some("hankin.io"), "huly.hankin.io")]
333    #[case::already_qualified("huly.hankin.io", Some("hankin.io"), "huly.hankin.io")]
334    #[case::zone_apex("@", Some("hankin.io"), "hankin.io")]
335    #[case::no_zone("huly.hankin.io", None, "huly.hankin.io")]
336    #[case::domain_equal_zone("hankin.io", Some("hankin.io"), "hankin.io")]
337    #[case::trailing_dots("huly.", Some("hankin.io."), "huly.hankin.io")]
338    #[case::mixed_case("Huly.Hankin.IO", Some("hankin.io"), "Huly.Hankin.IO")]
339    fn resolve_fqdn_preserves_existing_behavior(
340        #[case] domain: &str,
341        #[case] zone: Option<&str>,
342        #[case] expected: &str,
343    ) {
344        assert_eq!(resolve_fqdn(domain, zone), expected);
345    }
346
347    #[rstest]
348    #[case::subdomain("huly.hankin.io", Some("hankin.io"))]
349    #[case::single_label("hankin", None)]
350    #[case::trailing_dot("huly.hankin.io.", Some("hankin.io"))]
351    #[case::tld_guard_source("example.com", Some("com"))]
352    fn infer_zone_strips_first_label(#[case] fqdn: &str, #[case] expected: Option<&str>) {
353        assert_eq!(infer_zone(fqdn).as_deref(), expected);
354    }
355
356    #[rstest]
357    fn inferred_tld_is_filtered_by_callers_before_all_subdomains_query() {
358        let filtered = infer_zone("example.com").filter(|zone| zone.contains('.'));
359        assert!(filtered.is_none(), "TLD result should be filtered out");
360    }
361
362    #[rstest]
363    #[case::technitium(json!({"response": {"zones": [{"name": "hankin.io"}, {"name": "example.com"}]}}), vec!["hankin.io", "example.com"])]
364    #[case::pangolin(json!({"domains": [{"baseDomain": "app.hankin.io"}, {"baseDomain": "other.io"}]}), vec!["app.hankin.io", "other.io"])]
365    #[case::cloudflare(json!([{"id": "abc", "name": "hankin.io"}, {"id": "def", "name": "example.com"}]), vec!["hankin.io", "example.com"])]
366    #[case::unknown(json!({"other": "stuff"}), Vec::<&str>::new())]
367    fn extract_zone_names_handles_vendor_shapes(#[case] value: Value, #[case] expected: Vec<&str>) {
368        assert_eq!(extract_zone_names(&value), expected);
369    }
370
371    #[rstest]
372    #[tokio::test]
373    async fn list_records_for_all_zones_queries_each_zone_apex(options: ListRecordsOptions) {
374        let client = FakeZoneRead::new(json!({
375            "response": {
376                "zones": [{"name": "hankin.io"}, {"name": "example.com"}]
377            }
378        }));
379
380        let response = list_records_for_all_zones(&client, options)
381            .await
382            .expect("all zones should list");
383
384        let calls: Vec<(String, Option<String>)> = client
385            .calls()
386            .into_iter()
387            .map(|(domain, zone, _)| (domain, zone))
388            .collect();
389        assert_eq!(
390            calls,
391            vec![
392                ("hankin.io".to_string(), Some("hankin.io".to_string())),
393                ("example.com".to_string(), Some("example.com".to_string())),
394            ]
395        );
396        let zone_names: Vec<&str> = response
397            .zones
398            .iter()
399            .map(|z| z.zone.name.as_str())
400            .collect();
401        assert_eq!(zone_names, vec!["hankin.io", "example.com"]);
402    }
403
404    #[rstest]
405    #[tokio::test]
406    async fn list_records_for_all_zones_preserves_query_options(mixed_options: ListRecordsOptions) {
407        let client = FakeZoneRead::new(json!({"response": {"zones": [{"name": "hankin.io"}]}}));
408
409        list_records_for_all_zones(&client, mixed_options)
410            .await
411            .expect("all zones should list");
412
413        let actual_options = client.calls()[0].2;
414        assert_eq!(actual_options.use_local_ip, mixed_options.use_local_ip);
415        assert_eq!(actual_options.all_subdomains, mixed_options.all_subdomains);
416    }
417
418    #[rstest]
419    #[tokio::test]
420    async fn list_records_for_all_zones_empty_zones_returns_empty(options: ListRecordsOptions) {
421        let client = FakeZoneRead::new(json!({"response": {"zones": []}}));
422
423        let response = list_records_for_all_zones(&client, options)
424            .await
425            .expect("empty zones should still succeed");
426
427        assert!(client.calls().is_empty());
428        assert!(response.zones.is_empty());
429    }
430
431    #[rstest]
432    #[tokio::test]
433    async fn bare_label_search_queries_each_zone_with_label(options: ListRecordsOptions) {
434        let client = FakeZoneRead::new(
435            json!({"response": {"zones": [{"name": "hankin.io"}, {"name": "example.com"}]}}),
436        );
437
438        search_bare_label_in_zones(&client, "huly", false, options)
439            .await
440            .expect("bare label search should succeed");
441
442        let calls: Vec<(String, Option<String>)> = client
443            .calls()
444            .into_iter()
445            .map(|(domain, zone, _)| (domain, zone))
446            .collect();
447        assert_eq!(
448            calls,
449            vec![
450                ("huly.hankin.io".to_string(), Some("hankin.io".to_string())),
451                (
452                    "huly.example.com".to_string(),
453                    Some("example.com".to_string())
454                ),
455            ]
456        );
457    }
458
459    #[rstest]
460    #[tokio::test]
461    async fn bare_label_all_subdomains_queries_zone_apex_and_filters(
462        mixed_options: ListRecordsOptions,
463    ) {
464        let client = FakeZoneRead::new(json!({"response": {"zones": [{"name": "hankin.io"}]}}));
465
466        let response = search_bare_label_in_zones(&client, "huly", true, mixed_options)
467            .await
468            .expect("bare label all-subdomain search should succeed");
469
470        let calls = client.calls();
471        assert_eq!(calls.len(), 1);
472        assert_eq!(calls[0].0, "hankin.io");
473        assert_eq!(calls[0].1.as_deref(), Some("hankin.io"));
474        assert_eq!(calls[0].2.use_local_ip, mixed_options.use_local_ip);
475        assert_eq!(calls[0].2.all_subdomains, mixed_options.all_subdomains);
476        let names: Vec<&str> = response.zones[0]
477            .records
478            .iter()
479            .map(|record| record.name.as_str())
480            .collect();
481        assert_eq!(names, vec!["huly", "sub.huly"]);
482    }
483
484    #[rstest]
485    #[case::exact_relative(vec!["huly", "other"], "huly.hankin.io", false, vec!["huly"])]
486    #[case::exact_fqdn(vec!["huly.hankin.io", "other.hankin.io"], "huly.hankin.io", false, vec!["huly.hankin.io"])]
487    #[case::exact_trailing_dot(vec!["huly.hankin.io."], "huly.hankin.io", false, vec!["huly.hankin.io."])]
488    #[case::zone_apex(vec!["@", "www"], "hankin.io", false, vec!["@"]) ]
489    #[case::all_subdomains(vec!["huly", "sub.huly", "other", "@"], "huly.hankin.io", true, vec!["huly", "sub.huly"])]
490    #[case::duplicates(vec!["huly", "huly", "other"], "huly.hankin.io", false, vec!["huly", "huly"])]
491    #[case::mixed_case(vec!["Huly", "other"], "huly.hankin.io", false, vec!["Huly"])]
492    fn filter_records_by_domain_keeps_expected_matches(
493        #[case] record_names: Vec<&str>,
494        #[case] target: &str,
495        #[case] all_subdomains: bool,
496        #[case] expected_names: Vec<&str>,
497    ) {
498        let mut resp = ListRecordsResponse {
499            zones: vec![ZoneRecords {
500                zone: make_zone("hankin.io"),
501                records: record_names.into_iter().map(make_record).collect(),
502            }],
503        };
504
505        filter_records_by_domain(&mut resp, target, all_subdomains);
506
507        let names: Vec<&str> = resp
508            .zones
509            .first()
510            .map(|zone| {
511                zone.records
512                    .iter()
513                    .map(|record| record.name.as_str())
514                    .collect()
515            })
516            .unwrap_or_default();
517        assert_eq!(names, expected_names);
518    }
519
520    #[rstest]
521    fn filter_records_by_domain_drops_empty_zones() {
522        let mut resp = ListRecordsResponse {
523            zones: vec![ZoneRecords {
524                zone: make_zone("hankin.io"),
525                records: vec![make_record("other")],
526            }],
527        };
528
529        filter_records_by_domain(&mut resp, "huly.hankin.io", false);
530
531        assert!(resp.zones.is_empty());
532    }
533}