Skip to main content

dnslib/core/dns/
records.rs

1use std::net::{Ipv4Addr, Ipv6Addr};
2
3use clap::Subcommand;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::core::{
9    dns::{
10        responses::ListRecordsResponse,
11        service::{ListRecordsOptions, RecordWrite, ZoneRead},
12    },
13    error::Result,
14};
15
16pub mod query;
17
18/// List DNS records through a vendor-neutral zone reader.
19///
20/// # Errors
21///
22/// Returns any error reported by the selected DNS backend.
23pub async fn list_records<C: ZoneRead + ?Sized>(
24    client: &C,
25    domain: &str,
26    zone: Option<&str>,
27    options: ListRecordsOptions,
28) -> Result<ListRecordsResponse> {
29    client.list_records(domain, zone, options).await
30}
31
32/// Create a DNS record through a vendor-neutral record writer.
33///
34/// # Errors
35///
36/// Returns any error reported by the selected DNS backend.
37pub async fn create_record<C: RecordWrite + ?Sized>(
38    client: &C,
39    zone: &str,
40    domain: &str,
41    ttl: u32,
42    record: &RecordData,
43) -> Result<Value> {
44    client.add_record(zone, domain, ttl, record).await
45}
46
47/// Delete DNS records through a vendor-neutral record writer.
48///
49/// # Errors
50///
51/// Returns any error reported by the selected DNS backend.
52pub async fn delete_record<'a, C: RecordWrite + ?Sized>(
53    client: &'a C,
54    zone: &'a str,
55    domain: &'a str,
56    type_params: &'a [(&'a str, String)],
57) -> Result<Value> {
58    client.delete_record(zone, domain, type_params).await
59}
60
61// ─── Supporting enums ─────────────────────────────────────────────────────────
62
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64pub enum DsAlgorithm {
65    #[serde(rename = "RSAMD5")]
66    Rsamd5,
67    #[serde(rename = "DSA")]
68    Dsa,
69    #[serde(rename = "RSASHA1")]
70    Rsasha1,
71    #[serde(rename = "DSA-NSEC3-SHA1")]
72    DsaNsec3Sha1,
73    #[serde(rename = "RSASHA1-NSEC3-SHA1")]
74    Rsasha1Nsec3Sha1,
75    #[serde(rename = "RSASHA256")]
76    Rsasha256,
77    #[serde(rename = "RSASHA512")]
78    Rsasha512,
79    #[serde(rename = "ECC-GOST")]
80    EccGost,
81    #[serde(rename = "ECDSAP256SHA256")]
82    Ecdsap256sha256,
83    #[serde(rename = "ECDSAP384SHA384")]
84    Ecdsap384sha384,
85    #[serde(rename = "ED25519")]
86    Ed25519,
87    #[serde(rename = "ED448")]
88    Ed448,
89}
90
91impl DsAlgorithm {
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            Self::Rsamd5 => "RSAMD5",
95            Self::Dsa => "DSA",
96            Self::Rsasha1 => "RSASHA1",
97            Self::DsaNsec3Sha1 => "DSA-NSEC3-SHA1",
98            Self::Rsasha1Nsec3Sha1 => "RSASHA1-NSEC3-SHA1",
99            Self::Rsasha256 => "RSASHA256",
100            Self::Rsasha512 => "RSASHA512",
101            Self::EccGost => "ECC-GOST",
102            Self::Ecdsap256sha256 => "ECDSAP256SHA256",
103            Self::Ecdsap384sha384 => "ECDSAP384SHA384",
104            Self::Ed25519 => "ED25519",
105            Self::Ed448 => "ED448",
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub enum DigestType {
112    #[serde(rename = "SHA1")]
113    Sha1,
114    #[serde(rename = "SHA256")]
115    Sha256,
116    #[serde(rename = "GOST-R-34-11-94")]
117    GostR341194,
118    #[serde(rename = "SHA384")]
119    Sha384,
120}
121
122impl DigestType {
123    pub fn as_str(&self) -> &'static str {
124        match self {
125            Self::Sha1 => "SHA1",
126            Self::Sha256 => "SHA256",
127            Self::GostR341194 => "GOST-R-34-11-94",
128            Self::Sha384 => "SHA384",
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
134pub enum SshfpAlgorithm {
135    #[serde(rename = "RSA")]
136    Rsa,
137    #[serde(rename = "DSA")]
138    Dsa,
139    #[serde(rename = "ECDSA")]
140    Ecdsa,
141    #[serde(rename = "Ed25519")]
142    Ed25519,
143    #[serde(rename = "Ed448")]
144    Ed448,
145}
146
147impl SshfpAlgorithm {
148    pub fn as_str(&self) -> &'static str {
149        match self {
150            Self::Rsa => "RSA",
151            Self::Dsa => "DSA",
152            Self::Ecdsa => "ECDSA",
153            Self::Ed25519 => "Ed25519",
154            Self::Ed448 => "Ed448",
155        }
156    }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
160pub enum SshfpFingerprintType {
161    #[serde(rename = "SHA1")]
162    Sha1,
163    #[serde(rename = "SHA256")]
164    Sha256,
165}
166
167impl SshfpFingerprintType {
168    pub fn as_str(&self) -> &'static str {
169        match self {
170            Self::Sha1 => "SHA1",
171            Self::Sha256 => "SHA256",
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub enum TlsaCertUsage {
178    #[serde(rename = "PKIX-TA")]
179    PkixTa,
180    #[serde(rename = "PKIX-EE")]
181    PkixEe,
182    #[serde(rename = "DANE-TA")]
183    DaneTa,
184    #[serde(rename = "DANE-EE")]
185    DaneEe,
186}
187
188impl TlsaCertUsage {
189    pub fn as_str(&self) -> &'static str {
190        match self {
191            Self::PkixTa => "PKIX-TA",
192            Self::PkixEe => "PKIX-EE",
193            Self::DaneTa => "DANE-TA",
194            Self::DaneEe => "DANE-EE",
195        }
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
200pub enum TlsaSelector {
201    #[serde(rename = "Cert")]
202    Cert,
203    #[serde(rename = "SPKI")]
204    Spki,
205}
206
207impl TlsaSelector {
208    pub fn as_str(&self) -> &'static str {
209        match self {
210            Self::Cert => "Cert",
211            Self::Spki => "SPKI",
212        }
213    }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217pub enum TlsaMatchingType {
218    #[serde(rename = "Full")]
219    Full,
220    #[serde(rename = "SHA2-256")]
221    Sha2_256,
222    #[serde(rename = "SHA2-512")]
223    Sha2_512,
224}
225
226impl TlsaMatchingType {
227    pub fn as_str(&self) -> &'static str {
228        match self {
229            Self::Full => "Full",
230            Self::Sha2_256 => "SHA2-256",
231            Self::Sha2_512 => "SHA2-512",
232        }
233    }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237pub enum FwdProtocol {
238    Udp,
239    Tcp,
240    Tls,
241    Https,
242    Quic,
243}
244
245impl FwdProtocol {
246    pub fn as_str(&self) -> &'static str {
247        match self {
248            Self::Udp => "Udp",
249            Self::Tcp => "Tcp",
250            Self::Tls => "Tls",
251            Self::Https => "Https",
252            Self::Quic => "Quic",
253        }
254    }
255}
256
257// ─── RecordData ───────────────────────────────────────────────────────────────
258
259/// Typed DNS record data. Each variant holds exactly the fields required for
260/// that record type, mapping directly to Technitium API parameters.
261///
262/// Note: DNSKEY is intentionally absent — Technitium manages DNSKEY records
263/// automatically via its DNSSEC key management API, not via record add/delete.
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Subcommand)]
265#[serde(tag = "type", rename_all = "UPPERCASE")]
266#[command(rename_all = "lower")]
267pub enum RecordData {
268    /// IPv4 address  e.g. `a 1.2.3.4`
269    A {
270        #[serde(rename = "ipAddress")]
271        ip: Ipv4Addr,
272    },
273    /// IPv6 address  e.g. `aaaa 2001:db8::1`
274    Aaaa {
275        #[serde(rename = "ipAddress")]
276        ip: Ipv6Addr,
277    },
278    /// Apex alias (Technitium-specific)  e.g. `aname target.example.net`
279    Aname { aname: String },
280    /// DNS App record  e.g. `app "Split Horizon" "SplitHorizon.SimpleAddress" '{}'`
281    App {
282        #[serde(rename = "appName")]
283        app_name: String,
284        #[serde(rename = "classPath")]
285        class_path: String,
286        /// JSON data string passed to the app
287        #[serde(rename = "recordData")]
288        record_data: String,
289    },
290    /// CA Authorization  e.g. `caa letsencrypt.org --tag issue`
291    Caa {
292        value: String,
293        #[arg(long, default_value_t = 0)]
294        flags: u8,
295        /// issue, issuewild, or iodef
296        #[arg(long, default_value = "issue")]
297        tag: String,
298    },
299    /// Canonical name alias  e.g. `cname www.example.com`
300    Cname {
301        #[serde(rename = "cname")]
302        target: String,
303    },
304    /// Subtree redirect  e.g. `dname target.example.com`
305    Dname { dname: String },
306    /// DNSSEC delegation signer  e.g. `ds 12345 RSASHA256 SHA256 abcdef...`
307    Ds {
308        #[serde(rename = "keyTag")]
309        key_tag: u16,
310        algorithm: DsAlgorithm,
311        #[serde(rename = "digestType")]
312        digest_type: DigestType,
313        digest: String,
314    },
315    /// Conditional forwarder (Technitium-specific)  e.g. `fwd 1.1.1.1 --protocol Udp`
316    Fwd {
317        forwarder: String,
318        #[arg(long, default_value = "Udp")]
319        protocol: FwdProtocol,
320        #[serde(rename = "forwarderPriority", default = "default_fwd_priority")]
321        #[arg(long, default_value_t = 10)]
322        priority: u16,
323        #[serde(rename = "dnssecValidation", default)]
324        #[arg(long, default_value_t = false)]
325        dnssec_validation: bool,
326    },
327    /// HTTPS service binding  e.g. `https --svc-priority 1 svc.example.com`
328    Https {
329        #[serde(rename = "svcTargetName")]
330        svc_target_name: String,
331        #[serde(rename = "svcPriority")]
332        #[arg(long, default_value_t = 1)]
333        svc_priority: u16,
334        #[serde(rename = "svcParams")]
335        #[arg(long)]
336        svc_params: Option<String>,
337        #[serde(rename = "autoIpv4Hint", default)]
338        #[arg(long, default_value_t = false)]
339        auto_ipv4_hint: bool,
340        #[serde(rename = "autoIpv6Hint", default)]
341        #[arg(long, default_value_t = false)]
342        auto_ipv6_hint: bool,
343    },
344    /// Mail exchange  e.g. `mx mail.example.com --preference 10`
345    Mx {
346        exchange: String,
347        #[serde(default = "default_mx_preference")]
348        #[arg(long, default_value_t = 10)]
349        preference: u16,
350    },
351    /// Naming authority pointer  e.g. `naptr --order 10 --preference 20 ...`
352    Naptr {
353        #[serde(rename = "naptrOrder")]
354        #[arg(long)]
355        order: u16,
356        #[serde(rename = "naptrPreference")]
357        #[arg(long)]
358        preference: u16,
359        #[serde(rename = "naptrFlags")]
360        #[arg(long, default_value = "")]
361        flags: String,
362        #[serde(rename = "naptrServices")]
363        #[arg(long, default_value = "")]
364        services: String,
365        #[serde(rename = "naptrRegexp")]
366        #[arg(long, default_value = "")]
367        regexp: String,
368        #[serde(rename = "naptrReplacement")]
369        replacement: String,
370    },
371    /// Name server  e.g. `ns ns1.example.com` or `ns ns1.example.com --glue 1.2.3.4`
372    Ns {
373        #[serde(rename = "nameServer")]
374        nameserver: String,
375        #[arg(long)]
376        glue: Option<String>,
377    },
378    /// Reverse DNS pointer  e.g. `ptr host.example.com`
379    Ptr {
380        #[serde(rename = "ptrName")]
381        name: String,
382    },
383    /// SSH fingerprint  e.g. `sshfp RSA SHA256 abcdef...`
384    Sshfp {
385        #[serde(rename = "sshfpAlgorithm")]
386        algorithm: SshfpAlgorithm,
387        #[serde(rename = "sshfpFingerprintType")]
388        fingerprint_type: SshfpFingerprintType,
389        #[serde(rename = "sshfpFingerprint")]
390        fingerprint: String,
391    },
392    /// Service locator  e.g. `srv sip.example.com --port 5060 --priority 10 --weight 20`
393    Srv {
394        target: String,
395        #[arg(long)]
396        port: u16,
397        #[arg(long, default_value_t = 0)]
398        priority: u16,
399        #[arg(long, default_value_t = 0)]
400        weight: u16,
401    },
402    /// Service binding (generic)  e.g. `svcb --svc-priority 1 svc.example.com`
403    Svcb {
404        #[serde(rename = "svcTargetName")]
405        svc_target_name: String,
406        #[serde(rename = "svcPriority")]
407        #[arg(long, default_value_t = 1)]
408        svc_priority: u16,
409        #[serde(rename = "svcParams")]
410        #[arg(long)]
411        svc_params: Option<String>,
412        #[serde(rename = "autoIpv4Hint", default)]
413        #[arg(long, default_value_t = false)]
414        auto_ipv4_hint: bool,
415        #[serde(rename = "autoIpv6Hint", default)]
416        #[arg(long, default_value_t = false)]
417        auto_ipv6_hint: bool,
418    },
419    /// DANE TLS authentication  e.g. `tlsa DANE-EE SPKI SHA2-256 abcdef...`
420    Tlsa {
421        #[serde(rename = "tlsaCertificateUsage")]
422        cert_usage: TlsaCertUsage,
423        #[serde(rename = "tlsaSelector")]
424        selector: TlsaSelector,
425        #[serde(rename = "tlsaMatchingType")]
426        matching_type: TlsaMatchingType,
427        #[serde(rename = "tlsaCertificateAssociationData")]
428        cert_association_data: String,
429    },
430    /// Text record  e.g. `txt "v=spf1 ~all"` or `txt "long..." --split-text`
431    Txt {
432        text: String,
433        #[serde(rename = "splitText", default)]
434        #[arg(long, default_value_t = false)]
435        split_text: bool,
436    },
437    /// URI record  e.g. `uri https://example.com --priority 10 --weight 1`
438    Uri {
439        uri: String,
440        #[serde(rename = "uriPriority")]
441        #[arg(long, default_value_t = 10)]
442        priority: u16,
443        #[serde(rename = "uriWeight")]
444        #[arg(long, default_value_t = 1)]
445        weight: u16,
446    },
447    /// Raw/unknown type — rdata as colon-separated hex string  e.g. `unknown 0a0b0c...`
448    Unknown { rdata: String },
449}
450
451fn default_mx_preference() -> u16 {
452    10
453}
454fn default_fwd_priority() -> u16 {
455    10
456}
457
458impl RecordData {
459    pub fn type_name(&self) -> &'static str {
460        match self {
461            Self::A { .. } => "A",
462            Self::Aaaa { .. } => "AAAA",
463            Self::Aname { .. } => "ANAME",
464            Self::App { .. } => "APP",
465            Self::Caa { .. } => "CAA",
466            Self::Cname { .. } => "CNAME",
467            Self::Dname { .. } => "DNAME",
468            Self::Ds { .. } => "DS",
469            Self::Fwd { .. } => "FWD",
470            Self::Https { .. } => "HTTPS",
471            Self::Mx { .. } => "MX",
472            Self::Naptr { .. } => "NAPTR",
473            Self::Ns { .. } => "NS",
474            Self::Ptr { .. } => "PTR",
475            Self::Sshfp { .. } => "SSHFP",
476            Self::Srv { .. } => "SRV",
477            Self::Svcb { .. } => "SVCB",
478            Self::Tlsa { .. } => "TLSA",
479            Self::Txt { .. } => "TXT",
480            Self::Uri { .. } => "URI",
481            Self::Unknown { .. } => "UNKNOWN",
482        }
483    }
484
485    pub fn to_api_params(&self) -> Vec<(&'static str, String)> {
486        let mut p = vec![("type", self.type_name().into())];
487        match self {
488            Self::A { ip } => p.push(("ipAddress", ip.to_string())),
489            Self::Aaaa { ip } => p.push(("ipAddress", ip.to_string())),
490            Self::Aname { aname } => p.push(("aname", aname.clone())),
491            Self::App {
492                app_name,
493                class_path,
494                record_data,
495            } => {
496                p.push(("appName", app_name.clone()));
497                p.push(("classPath", class_path.clone()));
498                p.push(("recordData", record_data.clone()));
499            }
500            Self::Caa { flags, tag, value } => {
501                p.push(("flags", flags.to_string()));
502                p.push(("tag", tag.clone()));
503                p.push(("value", value.clone()));
504            }
505            Self::Cname { target } => p.push(("cname", target.clone())),
506            Self::Dname { dname } => p.push(("dname", dname.clone())),
507            Self::Ds {
508                key_tag,
509                algorithm,
510                digest_type,
511                digest,
512            } => {
513                p.push(("keyTag", key_tag.to_string()));
514                p.push(("algorithm", algorithm.as_str().into()));
515                p.push(("digestType", digest_type.as_str().into()));
516                p.push(("digest", digest.clone()));
517            }
518            Self::Fwd {
519                forwarder,
520                protocol,
521                priority,
522                dnssec_validation,
523            } => {
524                p.push(("forwarder", forwarder.clone()));
525                p.push(("protocol", protocol.as_str().into()));
526                p.push(("forwarderPriority", priority.to_string()));
527                p.push(("dnssecValidation", dnssec_validation.to_string()));
528            }
529            Self::Https {
530                svc_priority,
531                svc_target_name,
532                svc_params,
533                auto_ipv4_hint,
534                auto_ipv6_hint,
535            }
536            | Self::Svcb {
537                svc_priority,
538                svc_target_name,
539                svc_params,
540                auto_ipv4_hint,
541                auto_ipv6_hint,
542            } => {
543                p.push(("svcPriority", svc_priority.to_string()));
544                p.push(("svcTargetName", svc_target_name.clone()));
545                if let Some(params) = svc_params {
546                    p.push(("svcParams", params.clone()));
547                }
548                p.push(("autoIpv4Hint", auto_ipv4_hint.to_string()));
549                p.push(("autoIpv6Hint", auto_ipv6_hint.to_string()));
550            }
551            Self::Mx {
552                preference,
553                exchange,
554            } => {
555                p.push(("preference", preference.to_string()));
556                p.push(("exchange", exchange.clone()));
557            }
558            Self::Naptr {
559                order,
560                preference,
561                flags,
562                services,
563                regexp,
564                replacement,
565            } => {
566                p.push(("naptrOrder", order.to_string()));
567                p.push(("naptrPreference", preference.to_string()));
568                p.push(("naptrFlags", flags.clone()));
569                p.push(("naptrServices", services.clone()));
570                p.push(("naptrRegexp", regexp.clone()));
571                p.push(("naptrReplacement", replacement.clone()));
572            }
573            Self::Ns { nameserver, glue } => {
574                p.push(("nameServer", nameserver.clone()));
575                if let Some(g) = glue {
576                    p.push(("glue", g.clone()));
577                }
578            }
579            Self::Ptr { name } => p.push(("ptrName", name.clone())),
580            Self::Sshfp {
581                algorithm,
582                fingerprint_type,
583                fingerprint,
584            } => {
585                p.push(("sshfpAlgorithm", algorithm.as_str().into()));
586                p.push(("sshfpFingerprintType", fingerprint_type.as_str().into()));
587                p.push(("sshfpFingerprint", fingerprint.clone()));
588            }
589            Self::Srv {
590                priority,
591                weight,
592                port,
593                target,
594            } => {
595                p.push(("priority", priority.to_string()));
596                p.push(("weight", weight.to_string()));
597                p.push(("port", port.to_string()));
598                p.push(("target", target.clone()));
599            }
600            Self::Tlsa {
601                cert_usage,
602                selector,
603                matching_type,
604                cert_association_data,
605            } => {
606                p.push(("tlsaCertificateUsage", cert_usage.as_str().into()));
607                p.push(("tlsaSelector", selector.as_str().into()));
608                p.push(("tlsaMatchingType", matching_type.as_str().into()));
609                p.push((
610                    "tlsaCertificateAssociationData",
611                    cert_association_data.clone(),
612                ));
613            }
614            Self::Txt { text, split_text } => {
615                p.push(("text", text.clone()));
616                p.push(("splitText", split_text.to_string()));
617            }
618            Self::Uri {
619                priority,
620                weight,
621                uri,
622            } => {
623                p.push(("uriPriority", priority.to_string()));
624                p.push(("uriWeight", weight.to_string()));
625                p.push(("uri", uri.clone()));
626            }
627            Self::Unknown { rdata } => p.push(("rdata", rdata.clone())),
628        }
629        p
630    }
631}
632
633/// Identifies one or more records for deletion. Similar to [`RecordData`] but
634/// intentionally not identical — every value field is optional, and some variants
635/// omit fields that are only meaningful at creation time (e.g. `Caa`, `Ds`,
636/// `App`, `Https`). A missing field broadens the selector (e.g. `A { ip: None }`
637/// matches every A record at the domain); compare [`RecordData`] to understand
638/// which fields each variant actually exposes.
639///
640/// Derives both `Subcommand` (for clap-driven CLI parsing) and `Deserialize` +
641/// `JsonSchema` (for MCP tool params), so the CLI and MCP share one type.
642#[derive(Debug, Clone, Deserialize, JsonSchema, Subcommand)]
643#[serde(tag = "type", rename_all = "UPPERCASE")]
644#[command(rename_all = "lower")]
645pub enum RecordSelector {
646    /// e.g. `a` (all A records) or `a 1.2.3.4` (specific)
647    A {
648        #[serde(rename = "ipAddress")]
649        ip: Option<Ipv4Addr>,
650    },
651    /// e.g. `aaaa` or `aaaa 2001:db8::1`
652    Aaaa {
653        #[serde(rename = "ipAddress")]
654        ip: Option<Ipv6Addr>,
655    },
656    Aname {
657        aname: Option<String>,
658    },
659    App {
660        #[serde(rename = "appName")]
661        app_name: Option<String>,
662        #[serde(rename = "classPath")]
663        class_path: Option<String>,
664    },
665    Caa {
666        value: Option<String>,
667    },
668    Cname {
669        #[serde(rename = "cname")]
670        target: Option<String>,
671    },
672    Dname {
673        dname: Option<String>,
674    },
675    Ds {
676        #[serde(rename = "keyTag")]
677        key_tag: Option<u16>,
678    },
679    Fwd {
680        forwarder: Option<String>,
681    },
682    Https {
683        #[serde(rename = "svcTargetName")]
684        svc_target_name: Option<String>,
685    },
686    Mx {
687        exchange: Option<String>,
688    },
689    Naptr {
690        #[serde(rename = "naptrReplacement")]
691        replacement: Option<String>,
692    },
693    Ns {
694        #[serde(rename = "nameServer")]
695        nameserver: Option<String>,
696    },
697    Ptr {
698        #[serde(rename = "ptrName")]
699        name: Option<String>,
700    },
701    Sshfp {
702        #[serde(rename = "sshfpFingerprint")]
703        fingerprint: Option<String>,
704    },
705    Srv {
706        target: Option<String>,
707        #[arg(long)]
708        port: Option<u16>,
709        #[arg(long)]
710        priority: Option<u16>,
711        #[arg(long)]
712        weight: Option<u16>,
713    },
714    Svcb {
715        #[serde(rename = "svcTargetName")]
716        svc_target_name: Option<String>,
717    },
718    Tlsa {
719        #[serde(rename = "tlsaCertificateAssociationData")]
720        cert_association_data: Option<String>,
721    },
722    Txt {
723        text: Option<String>,
724    },
725    Uri {
726        uri: Option<String>,
727    },
728    Unknown {
729        rdata: Option<String>,
730    },
731}
732
733impl RecordSelector {
734    pub fn type_name(&self) -> &'static str {
735        match self {
736            Self::A { .. } => "A",
737            Self::Aaaa { .. } => "AAAA",
738            Self::Aname { .. } => "ANAME",
739            Self::App { .. } => "APP",
740            Self::Caa { .. } => "CAA",
741            Self::Cname { .. } => "CNAME",
742            Self::Dname { .. } => "DNAME",
743            Self::Ds { .. } => "DS",
744            Self::Fwd { .. } => "FWD",
745            Self::Https { .. } => "HTTPS",
746            Self::Mx { .. } => "MX",
747            Self::Naptr { .. } => "NAPTR",
748            Self::Ns { .. } => "NS",
749            Self::Ptr { .. } => "PTR",
750            Self::Sshfp { .. } => "SSHFP",
751            Self::Srv { .. } => "SRV",
752            Self::Svcb { .. } => "SVCB",
753            Self::Tlsa { .. } => "TLSA",
754            Self::Txt { .. } => "TXT",
755            Self::Uri { .. } => "URI",
756            Self::Unknown { .. } => "UNKNOWN",
757        }
758    }
759
760    pub fn to_api_params(&self) -> Vec<(&'static str, String)> {
761        let mut p = vec![("type", self.type_name().into())];
762        match self {
763            Self::A { ip } => {
764                if let Some(v) = ip {
765                    p.push(("ipAddress", v.to_string()));
766                }
767            }
768            Self::Aaaa { ip } => {
769                if let Some(v) = ip {
770                    p.push(("ipAddress", v.to_string()));
771                }
772            }
773            Self::Aname { aname } => {
774                if let Some(v) = aname {
775                    p.push(("aname", v.clone()));
776                }
777            }
778            Self::App {
779                app_name,
780                class_path,
781            } => {
782                if let Some(v) = app_name {
783                    p.push(("appName", v.clone()));
784                }
785                if let Some(v) = class_path {
786                    p.push(("classPath", v.clone()));
787                }
788            }
789            Self::Caa { value } => {
790                if let Some(v) = value {
791                    p.push(("value", v.clone()));
792                }
793            }
794            Self::Cname { target } => {
795                if let Some(v) = target {
796                    p.push(("cname", v.clone()));
797                }
798            }
799            Self::Dname { dname } => {
800                if let Some(v) = dname {
801                    p.push(("dname", v.clone()));
802                }
803            }
804            Self::Ds { key_tag } => {
805                if let Some(v) = key_tag {
806                    p.push(("keyTag", v.to_string()));
807                }
808            }
809            Self::Fwd { forwarder } => {
810                if let Some(v) = forwarder {
811                    p.push(("forwarder", v.clone()));
812                }
813            }
814            Self::Https { svc_target_name } | Self::Svcb { svc_target_name } => {
815                if let Some(v) = svc_target_name {
816                    p.push(("svcTargetName", v.clone()));
817                }
818            }
819            Self::Mx { exchange } => {
820                if let Some(v) = exchange {
821                    p.push(("exchange", v.clone()));
822                }
823            }
824            Self::Naptr { replacement } => {
825                if let Some(v) = replacement {
826                    p.push(("naptrReplacement", v.clone()));
827                }
828            }
829            Self::Ns { nameserver } => {
830                if let Some(v) = nameserver {
831                    p.push(("nameServer", v.clone()));
832                }
833            }
834            Self::Ptr { name } => {
835                if let Some(v) = name {
836                    p.push(("ptrName", v.clone()));
837                }
838            }
839            Self::Sshfp { fingerprint } => {
840                if let Some(v) = fingerprint {
841                    p.push(("sshfpFingerprint", v.clone()));
842                }
843            }
844            Self::Srv {
845                target,
846                port,
847                priority,
848                weight,
849            } => {
850                if let Some(v) = target {
851                    p.push(("target", v.clone()));
852                }
853                if let Some(v) = port {
854                    p.push(("port", v.to_string()));
855                }
856                if let Some(v) = priority {
857                    p.push(("priority", v.to_string()));
858                }
859                if let Some(v) = weight {
860                    p.push(("weight", v.to_string()));
861                }
862            }
863            Self::Tlsa {
864                cert_association_data,
865            } => {
866                if let Some(v) = cert_association_data {
867                    p.push(("tlsaCertificateAssociationData", v.clone()));
868                }
869            }
870            Self::Txt { text } => {
871                if let Some(v) = text {
872                    p.push(("text", v.clone()));
873                }
874            }
875            Self::Uri { uri } => {
876                if let Some(v) = uri {
877                    p.push(("uri", v.clone()));
878                }
879            }
880            Self::Unknown { rdata } => {
881                if let Some(v) = rdata {
882                    p.push(("rdata", v.clone()));
883                }
884            }
885        }
886        p
887    }
888}
889
890// ─── Tests ────────────────────────────────────────────────────────────────────
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use rstest::{fixture, rstest};
896
897    // ── Fixtures ──────────────────────────────────────────────────────────────
898
899    #[fixture]
900    fn a_record() -> RecordData {
901        RecordData::A {
902            ip: "1.2.3.4".parse().unwrap(),
903        }
904    }
905
906    #[fixture]
907    fn mx_record() -> RecordData {
908        RecordData::Mx {
909            preference: 10,
910            exchange: "mail.example.com".into(),
911        }
912    }
913
914    #[fixture]
915    fn srv_record() -> RecordData {
916        RecordData::Srv {
917            priority: 10,
918            weight: 20,
919            port: 5060,
920            target: "sip.example.com".into(),
921        }
922    }
923
924    #[fixture]
925    fn ns_with_glue() -> RecordData {
926        RecordData::Ns {
927            nameserver: "ns1.example.com".into(),
928            glue: Some("1.2.3.4".into()),
929        }
930    }
931
932    #[fixture]
933    fn ns_without_glue() -> RecordData {
934        RecordData::Ns {
935            nameserver: "ns1.example.com".into(),
936            glue: None,
937        }
938    }
939
940    // ── type_name — every variant ─────────────────────────────────────────────
941
942    #[rstest]
943    #[case::a(RecordData::A { ip: "1.2.3.4".parse().unwrap() }, "A")]
944    #[case::aaaa(RecordData::Aaaa { ip: "::1".parse().unwrap() }, "AAAA")]
945    #[case::aname(RecordData::Aname { aname: "t.example.com".into() }, "ANAME")]
946    #[case::app(RecordData::App { app_name: "App".into(), class_path: "C".into(), record_data: "{}".into() }, "APP")]
947    #[case::caa(RecordData::Caa { flags: 0, tag: "issue".into(), value: "le.org".into() }, "CAA")]
948    #[case::cname(RecordData::Cname { target: "www.example.com".into() }, "CNAME")]
949    #[case::dname(RecordData::Dname { dname: "other.example.com".into() }, "DNAME")]
950    #[case::ds(RecordData::Ds { key_tag: 1, algorithm: DsAlgorithm::Rsasha256, digest_type: DigestType::Sha256, digest: "ab".into() }, "DS")]
951    #[case::fwd(RecordData::Fwd { forwarder: "1.1.1.1".into(), protocol: FwdProtocol::Udp, priority: 10, dnssec_validation: false }, "FWD")]
952    #[case::https(RecordData::Https { svc_priority: 1, svc_target_name: "svc.example.com".into(), svc_params: None, auto_ipv4_hint: false, auto_ipv6_hint: false }, "HTTPS")]
953    #[case::mx(RecordData::Mx { preference: 10, exchange: "mail.example.com".into() }, "MX")]
954    #[case::naptr(RecordData::Naptr { order: 10, preference: 20, flags: "U".into(), services: "E2U+sip".into(), regexp: "".into(), replacement: ".".into() }, "NAPTR")]
955    #[case::ns(RecordData::Ns { nameserver: "ns1.example.com".into(), glue: None }, "NS")]
956    #[case::ptr(RecordData::Ptr { name: "host.example.com".into() }, "PTR")]
957    #[case::sshfp(RecordData::Sshfp { algorithm: SshfpAlgorithm::Rsa, fingerprint_type: SshfpFingerprintType::Sha256, fingerprint: "abcd".into() }, "SSHFP")]
958    #[case::srv(RecordData::Srv { priority: 0, weight: 0, port: 80, target: "t.example.com".into() }, "SRV")]
959    #[case::svcb(RecordData::Svcb { svc_priority: 1, svc_target_name: "svc.example.com".into(), svc_params: None, auto_ipv4_hint: false, auto_ipv6_hint: false }, "SVCB")]
960    #[case::tlsa(RecordData::Tlsa { cert_usage: TlsaCertUsage::DaneEe, selector: TlsaSelector::Spki, matching_type: TlsaMatchingType::Sha2_256, cert_association_data: "ab".into() }, "TLSA")]
961    #[case::txt(RecordData::Txt { text: "v=spf1 ~all".into(), split_text: false }, "TXT")]
962    #[case::uri(RecordData::Uri { priority: 1, weight: 1, uri: "https://example.com".into() }, "URI")]
963    #[case::unknown(RecordData::Unknown { rdata: "0a0b".into() }, "UNKNOWN")]
964    fn type_name_matches_variant(#[case] record: RecordData, #[case] expected: &str) {
965        assert_eq!(record.type_name(), expected);
966    }
967
968    // ── to_api_params — correct field names ───────────────────────────────────
969
970    fn params_map(record: &RecordData) -> std::collections::HashMap<&'static str, String> {
971        record.to_api_params().into_iter().collect()
972    }
973
974    #[rstest]
975    fn a_uses_ip_address_key(a_record: RecordData) {
976        let p = params_map(&a_record);
977        assert_eq!(p["type"], "A");
978        assert_eq!(p["ipAddress"], "1.2.3.4");
979        // Must NOT use "ip" — that's our internal field name
980        assert!(!p.contains_key("ip"));
981    }
982
983    #[rstest]
984    fn aaaa_uses_ip_address_key() {
985        let r = RecordData::Aaaa {
986            ip: "2001:db8::1".parse().unwrap(),
987        };
988        let p = params_map(&r);
989        assert_eq!(p["type"], "AAAA");
990        assert_eq!(p["ipAddress"], "2001:db8::1");
991    }
992
993    #[rstest]
994    fn mx_uses_exchange_and_preference(mx_record: RecordData) {
995        let p = params_map(&mx_record);
996        assert_eq!(p["type"], "MX");
997        assert_eq!(p["exchange"], "mail.example.com");
998        assert_eq!(p["preference"], "10");
999    }
1000
1001    #[rstest]
1002    fn ns_uses_name_server_key(ns_without_glue: RecordData) {
1003        let p = params_map(&ns_without_glue);
1004        assert_eq!(p["type"], "NS");
1005        assert_eq!(p["nameServer"], "ns1.example.com"); // camelCase, not "nameserver"
1006        assert!(!p.contains_key("glue"));
1007    }
1008
1009    #[rstest]
1010    fn ns_includes_glue_when_present(ns_with_glue: RecordData) {
1011        let p = params_map(&ns_with_glue);
1012        assert_eq!(p["glue"], "1.2.3.4");
1013    }
1014
1015    #[rstest]
1016    fn ptr_uses_ptr_name_key() {
1017        let r = RecordData::Ptr {
1018            name: "host.example.com".into(),
1019        };
1020        let p = params_map(&r);
1021        assert_eq!(p["ptrName"], "host.example.com");
1022        assert!(!p.contains_key("name"));
1023    }
1024
1025    #[rstest]
1026    fn cname_uses_cname_key() {
1027        let r = RecordData::Cname {
1028            target: "www.example.com".into(),
1029        };
1030        let p = params_map(&r);
1031        assert_eq!(p["cname"], "www.example.com");
1032        assert!(!p.contains_key("target"));
1033    }
1034
1035    #[rstest]
1036    fn srv_uses_correct_keys(srv_record: RecordData) {
1037        let p = params_map(&srv_record);
1038        assert_eq!(p["type"], "SRV");
1039        assert_eq!(p["priority"], "10");
1040        assert_eq!(p["weight"], "20");
1041        assert_eq!(p["port"], "5060");
1042        assert_eq!(p["target"], "sip.example.com");
1043    }
1044
1045    #[rstest]
1046    fn ds_uses_camel_case_keys() {
1047        let r = RecordData::Ds {
1048            key_tag: 12345,
1049            algorithm: DsAlgorithm::Ecdsap256sha256,
1050            digest_type: DigestType::Sha256,
1051            digest: "deadbeef".into(),
1052        };
1053        let p = params_map(&r);
1054        assert_eq!(p["keyTag"], "12345");
1055        assert_eq!(p["algorithm"], "ECDSAP256SHA256");
1056        assert_eq!(p["digestType"], "SHA256");
1057        assert_eq!(p["digest"], "deadbeef");
1058    }
1059
1060    #[rstest]
1061    fn tlsa_uses_full_key_names() {
1062        let r = RecordData::Tlsa {
1063            cert_usage: TlsaCertUsage::DaneTa,
1064            selector: TlsaSelector::Cert,
1065            matching_type: TlsaMatchingType::Sha2_512,
1066            cert_association_data: "cafebabe".into(),
1067        };
1068        let p = params_map(&r);
1069        assert_eq!(p["tlsaCertificateUsage"], "DANE-TA");
1070        assert_eq!(p["tlsaSelector"], "Cert");
1071        assert_eq!(p["tlsaMatchingType"], "SHA2-512");
1072        assert_eq!(p["tlsaCertificateAssociationData"], "cafebabe");
1073    }
1074
1075    #[rstest]
1076    fn fwd_uses_forwarder_priority_key() {
1077        let r = RecordData::Fwd {
1078            forwarder: "8.8.8.8".into(),
1079            protocol: FwdProtocol::Tls,
1080            priority: 5,
1081            dnssec_validation: true,
1082        };
1083        let p = params_map(&r);
1084        assert_eq!(p["forwarder"], "8.8.8.8");
1085        assert_eq!(p["protocol"], "Tls");
1086        assert_eq!(p["forwarderPriority"], "5"); // NOT "priority"
1087        assert_eq!(p["dnssecValidation"], "true");
1088    }
1089
1090    #[rstest]
1091    fn https_and_svcb_use_svc_prefix() {
1092        let https = RecordData::Https {
1093            svc_priority: 1,
1094            svc_target_name: "svc.example.com".into(),
1095            svc_params: Some("alpn|h2".into()),
1096            auto_ipv4_hint: true,
1097            auto_ipv6_hint: false,
1098        };
1099        let svcb = RecordData::Svcb {
1100            svc_priority: 1,
1101            svc_target_name: "svc.example.com".into(),
1102            svc_params: Some("alpn|h2".into()),
1103            auto_ipv4_hint: true,
1104            auto_ipv6_hint: false,
1105        };
1106        for r in [&https, &svcb] {
1107            let p = params_map(r);
1108            assert_eq!(p["svcPriority"], "1");
1109            assert_eq!(p["svcTargetName"], "svc.example.com");
1110            assert_eq!(p["svcParams"], "alpn|h2");
1111            assert_eq!(p["autoIpv4Hint"], "true");
1112            assert_eq!(p["autoIpv6Hint"], "false");
1113        }
1114    }
1115
1116    #[rstest]
1117    fn https_omits_svc_params_when_none() {
1118        let r = RecordData::Https {
1119            svc_priority: 1,
1120            svc_target_name: "svc.example.com".into(),
1121            svc_params: None,
1122            auto_ipv4_hint: false,
1123            auto_ipv6_hint: false,
1124        };
1125        let p = params_map(&r);
1126        assert!(!p.contains_key("svcParams"));
1127    }
1128
1129    #[rstest]
1130    fn uri_uses_uri_prefix_keys() {
1131        let r = RecordData::Uri {
1132            priority: 5,
1133            weight: 3,
1134            uri: "https://example.com/path".into(),
1135        };
1136        let p = params_map(&r);
1137        assert_eq!(p["uriPriority"], "5");
1138        assert_eq!(p["uriWeight"], "3");
1139        assert_eq!(p["uri"], "https://example.com/path");
1140    }
1141
1142    #[rstest]
1143    fn naptr_uses_naptr_prefix_keys() {
1144        let r = RecordData::Naptr {
1145            order: 10,
1146            preference: 20,
1147            flags: "U".into(),
1148            services: "E2U+sip".into(),
1149            regexp: "!^.*$!sip:info@example.com!".into(),
1150            replacement: ".".into(),
1151        };
1152        let p = params_map(&r);
1153        assert_eq!(p["naptrOrder"], "10");
1154        assert_eq!(p["naptrPreference"], "20");
1155        assert_eq!(p["naptrFlags"], "U");
1156        assert_eq!(p["naptrServices"], "E2U+sip");
1157        assert_eq!(p["naptrRegexp"], "!^.*$!sip:info@example.com!");
1158        assert_eq!(p["naptrReplacement"], ".");
1159    }
1160
1161    #[rstest]
1162    fn txt_includes_split_text_flag() {
1163        let r = RecordData::Txt {
1164            text: "v=spf1 ~all".into(),
1165            split_text: true,
1166        };
1167        let p = params_map(&r);
1168        assert_eq!(p["text"], "v=spf1 ~all");
1169        assert_eq!(p["splitText"], "true");
1170    }
1171
1172    // ── type_name is always first param ──────────────────────────────────────
1173
1174    #[rstest]
1175    fn type_param_is_always_first(
1176        #[values(
1177            RecordData::A { ip: "1.2.3.4".parse().unwrap() },
1178            RecordData::Cname { target: "www.example.com".into() },
1179            RecordData::Txt { text: "test".into(), split_text: false }
1180        )]
1181        record: RecordData,
1182    ) {
1183        let params = record.to_api_params();
1184        assert_eq!(params[0].0, "type");
1185        assert_eq!(params[0].1, record.type_name());
1186    }
1187}