1use serde_json::Value;
2
3use crate::core::{
4 dns::{
5 responses::ListRecordsResponse,
6 service::{ListRecordsOptions, ZoneRead},
7 },
8 error::Result,
9};
10
11#[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#[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#[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
93pub 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
142pub 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 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
186pub 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
211pub 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}