Skip to main content

dnslib/cli/
records.rs

1//! CLI display for DNS records.
2
3use clap::ValueEnum;
4use clap::builder::PossibleValue;
5
6use crate::core::dns::records::{
7    DigestType, DsAlgorithm, FwdProtocol, SshfpAlgorithm, SshfpFingerprintType, TlsaCertUsage,
8    TlsaMatchingType, TlsaSelector,
9};
10use crate::core::dns::responses::ListRecordsResponse;
11
12macro_rules! impl_value_enum {
13    ($ty:ty, [$($variant:expr),+ $(,)?]) => {
14        impl ValueEnum for $ty {
15            fn value_variants<'a>() -> &'a [Self]
16            where
17                Self: 'a,
18            {
19                &[$($variant),+]
20            }
21
22            fn to_possible_value(&self) -> Option<PossibleValue> {
23                Some(PossibleValue::new(self.as_str()))
24            }
25        }
26    };
27}
28
29impl_value_enum!(
30    DsAlgorithm,
31    [
32        DsAlgorithm::Rsamd5,
33        DsAlgorithm::Dsa,
34        DsAlgorithm::Rsasha1,
35        DsAlgorithm::DsaNsec3Sha1,
36        DsAlgorithm::Rsasha1Nsec3Sha1,
37        DsAlgorithm::Rsasha256,
38        DsAlgorithm::Rsasha512,
39        DsAlgorithm::EccGost,
40        DsAlgorithm::Ecdsap256sha256,
41        DsAlgorithm::Ecdsap384sha384,
42        DsAlgorithm::Ed25519,
43        DsAlgorithm::Ed448,
44    ]
45);
46
47impl_value_enum!(
48    DigestType,
49    [
50        DigestType::Sha1,
51        DigestType::Sha256,
52        DigestType::GostR341194,
53        DigestType::Sha384,
54    ]
55);
56
57impl_value_enum!(
58    SshfpAlgorithm,
59    [
60        SshfpAlgorithm::Rsa,
61        SshfpAlgorithm::Dsa,
62        SshfpAlgorithm::Ecdsa,
63        SshfpAlgorithm::Ed25519,
64        SshfpAlgorithm::Ed448,
65    ]
66);
67
68impl_value_enum!(
69    SshfpFingerprintType,
70    [SshfpFingerprintType::Sha1, SshfpFingerprintType::Sha256,]
71);
72
73impl_value_enum!(
74    TlsaCertUsage,
75    [
76        TlsaCertUsage::PkixTa,
77        TlsaCertUsage::PkixEe,
78        TlsaCertUsage::DaneTa,
79        TlsaCertUsage::DaneEe,
80    ]
81);
82
83impl_value_enum!(TlsaSelector, [TlsaSelector::Cert, TlsaSelector::Spki]);
84
85impl_value_enum!(
86    TlsaMatchingType,
87    [
88        TlsaMatchingType::Full,
89        TlsaMatchingType::Sha2_256,
90        TlsaMatchingType::Sha2_512,
91    ]
92);
93
94impl_value_enum!(
95    FwdProtocol,
96    [
97        FwdProtocol::Udp,
98        FwdProtocol::Tcp,
99        FwdProtocol::Tls,
100        FwdProtocol::Https,
101        FwdProtocol::Quic,
102    ]
103);
104
105// ─── Content extraction ───────────────────────────────────────────────────────
106
107/// Returns a compact, human-readable string for the data portion of a record.
108pub fn record_content(record_type: &str, data: &serde_json::Value) -> String {
109    match record_type.to_uppercase().as_str() {
110        "A" | "AAAA" => str_field(data, "ipAddress"),
111        "CNAME" => str_field(data, "cname"),
112        "ANAME" => str_field(data, "aname"),
113        "DNAME" => str_field(data, "dname"),
114        "NS" => str_field(data, "nameServer"),
115        "PTR" => str_field(data, "ptrName"),
116        "TXT" => str_field(data, "text"),
117        "MX" => format!(
118            "{} {}",
119            data.get("preference")
120                .and_then(|v| v.as_u64())
121                .unwrap_or(10),
122            str_field(data, "exchange"),
123        ),
124        "SRV" => format!(
125            "{} {} {} {}",
126            data.get("priority").and_then(|v| v.as_u64()).unwrap_or(0),
127            data.get("weight").and_then(|v| v.as_u64()).unwrap_or(0),
128            data.get("port").and_then(|v| v.as_u64()).unwrap_or(0),
129            str_field(data, "target"),
130        ),
131        "CAA" => format!(
132            "{} {} \"{}\"",
133            data.get("flags").and_then(|v| v.as_u64()).unwrap_or(0),
134            str_field(data, "tag"),
135            str_field(data, "value"),
136        ),
137        "SSHFP" => format!(
138            "{} {} {}",
139            str_field(data, "sshfpAlgorithm"),
140            str_field(data, "sshfpFingerprintType"),
141            str_field(data, "sshfpFingerprint"),
142        ),
143        "TLSA" => format!(
144            "{} {} {} {}",
145            str_field(data, "tlsaCertificateUsage"),
146            str_field(data, "tlsaSelector"),
147            str_field(data, "tlsaMatchingType"),
148            str_field(data, "tlsaCertificateAssociationData"),
149        ),
150        "DS" => format!(
151            "{} {} {} {}",
152            data.get("keyTag").and_then(|v| v.as_u64()).unwrap_or(0),
153            str_field(data, "algorithm"),
154            str_field(data, "digestType"),
155            str_field(data, "digest"),
156        ),
157        "HTTPS" | "SVCB" => format!(
158            "{} {}",
159            data.get("svcPriority")
160                .and_then(|v| v.as_u64())
161                .unwrap_or(1),
162            str_field(data, "svcTargetName"),
163        ),
164        "FWD" => str_field(data, "forwarder"),
165        "NAPTR" => format!(
166            "{} {} \"{}\" \"{}\" \"{}\" {}",
167            data.get("naptrOrder").and_then(|v| v.as_u64()).unwrap_or(0),
168            data.get("naptrPreference")
169                .and_then(|v| v.as_u64())
170                .unwrap_or(0),
171            str_field(data, "naptrFlags"),
172            str_field(data, "naptrServices"),
173            str_field(data, "naptrRegexp"),
174            str_field(data, "naptrReplacement"),
175        ),
176        _ => {
177            // Try a "value" key (Pangolin generic), then fall back to compact JSON.
178            if let Some(v) = data.get("value").and_then(|v| v.as_str()) {
179                return v.to_string();
180            }
181            serde_json::to_string(data).unwrap_or_default()
182        }
183    }
184}
185
186fn str_field(data: &serde_json::Value, key: &str) -> String {
187    data.get(key)
188        .and_then(|v| v.as_str())
189        .unwrap_or("")
190        .to_string()
191}
192
193// ─── Table display ────────────────────────────────────────────────────────────
194
195const COL_NAME: &str = "HOST";
196const COL_TYPE: &str = "TYPE";
197const COL_TTL: &str = "TTL";
198const COL_DATA: &str = "DATA";
199
200/// Print `response` as an aligned table.
201///
202/// Each zone gets its own header block. Disabled zones and records are
203/// flagged inline.
204pub fn print_records_table(response: &ListRecordsResponse) {
205    let total = response.zones.len();
206
207    for (i, zone_records) in response.zones.iter().enumerate() {
208        let zone = &zone_records.zone;
209
210        // Zone header.
211        if zone.disabled {
212            println!("Zone: {}  [{}]  [disabled]", zone.name, zone.zone_type);
213        } else {
214            println!("Zone: {}  [{}]", zone.name, zone.zone_type);
215        }
216
217        if zone_records.records.is_empty() {
218            println!("  (no records)");
219        } else {
220            // Compute column widths.
221            let name_w = zone_records
222                .records
223                .iter()
224                .map(|r| r.name.len())
225                .max()
226                .unwrap_or(0)
227                .max(COL_NAME.len());
228
229            let type_w = zone_records
230                .records
231                .iter()
232                .map(|r| r.record_type.len())
233                .max()
234                .unwrap_or(0)
235                .max(COL_TYPE.len());
236
237            let ttl_w = zone_records
238                .records
239                .iter()
240                .map(|r| r.ttl.to_string().len())
241                .max()
242                .unwrap_or(0)
243                .max(COL_TTL.len());
244
245            // Header row.
246            println!();
247            println!(
248                "{:<name_w$}  {:<type_w$}  {:>ttl_w$}  {}",
249                COL_NAME, COL_TYPE, COL_TTL, COL_DATA,
250            );
251            println!("{}", "-".repeat(name_w + type_w + ttl_w + 8));
252
253            // Data rows.
254            for record in &zone_records.records {
255                let content = record_content(&record.record_type, &record.data);
256                let disabled = if record.disabled { "  [disabled]" } else { "" };
257
258                println!(
259                    "{:<name_w$}  {:<type_w$}  {:>ttl_w$}  {}{}",
260                    record.name, record.record_type, record.ttl, content, disabled,
261                );
262            }
263        }
264
265        // Blank line between zones but not after the last one.
266        if i + 1 < total {
267            println!();
268        }
269    }
270}
271
272// ─── Tests ────────────────────────────────────────────────────────────────────
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::core::dns::records::RecordSelector;
278    use rstest::rstest;
279    use serde_json::json;
280
281    #[rstest]
282    #[case::a_none(RecordSelector::A { ip: None }, vec![("type", "A")])]
283    #[case::a_some(
284        RecordSelector::A { ip: Some("1.2.3.4".parse().unwrap()) },
285        vec![("type", "A"), ("ipAddress", "1.2.3.4")]
286    )]
287    #[case::aaaa_some(
288        RecordSelector::Aaaa { ip: Some("2001:db8::1".parse().unwrap()) },
289        vec![("type", "AAAA"), ("ipAddress", "2001:db8::1")]
290    )]
291    #[case::mx_some(
292        RecordSelector::Mx { exchange: Some("mail.example.com".into()) },
293        vec![("type", "MX"), ("exchange", "mail.example.com")]
294    )]
295    #[case::txt_some(
296        RecordSelector::Txt { text: Some("v=spf1 ~all".into()) },
297        vec![("type", "TXT"), ("text", "v=spf1 ~all")]
298    )]
299    fn record_selector_to_api_params_matches_expected(
300        #[case] selector: RecordSelector,
301        #[case] expected: Vec<(&'static str, &'static str)>,
302    ) {
303        let actual = selector.to_api_params();
304        let expected: Vec<(&str, String)> = expected
305            .into_iter()
306            .map(|(key, value)| (key, value.to_string()))
307            .collect();
308
309        assert_eq!(actual, expected);
310    }
311
312    #[test]
313    fn a_record_content() {
314        assert_eq!(
315            record_content("A", &json!({"ipAddress": "1.2.3.4"})),
316            "1.2.3.4"
317        );
318    }
319
320    #[test]
321    fn aaaa_record_content() {
322        assert_eq!(
323            record_content("AAAA", &json!({"ipAddress": "2001:db8::1"})),
324            "2001:db8::1"
325        );
326    }
327
328    #[test]
329    fn cname_record_content() {
330        assert_eq!(
331            record_content("CNAME", &json!({"cname": "target.example.com"})),
332            "target.example.com"
333        );
334    }
335
336    #[test]
337    fn mx_record_content_includes_preference() {
338        assert_eq!(
339            record_content(
340                "MX",
341                &json!({"preference": 10, "exchange": "mail.example.com"})
342            ),
343            "10 mail.example.com"
344        );
345    }
346
347    #[test]
348    fn mx_record_content_defaults_preference_to_10() {
349        assert_eq!(
350            record_content("MX", &json!({"exchange": "mail.example.com"})),
351            "10 mail.example.com"
352        );
353    }
354
355    #[test]
356    fn txt_record_content() {
357        assert_eq!(
358            record_content("TXT", &json!({"text": "v=spf1 ~all"})),
359            "v=spf1 ~all"
360        );
361    }
362
363    #[test]
364    fn ns_record_content() {
365        assert_eq!(
366            record_content(
367                "NS",
368                &json!({"nameServer": "ns1.example.com", "glue": null})
369            ),
370            "ns1.example.com"
371        );
372    }
373
374    #[test]
375    fn srv_record_content() {
376        assert_eq!(
377            record_content(
378                "SRV",
379                &json!({"priority": 10, "weight": 20, "port": 5060, "target": "sip.example.com"})
380            ),
381            "10 20 5060 sip.example.com"
382        );
383    }
384
385    #[test]
386    fn caa_record_content() {
387        assert_eq!(
388            record_content(
389                "CAA",
390                &json!({"flags": 0, "tag": "issue", "value": "letsencrypt.org"})
391            ),
392            "0 issue \"letsencrypt.org\""
393        );
394    }
395
396    #[test]
397    fn ds_record_content() {
398        assert_eq!(
399            record_content(
400                "DS",
401                &json!({"keyTag": 12345, "algorithm": "RSASHA256", "digestType": "SHA256", "digest": "abcdef"})
402            ),
403            "12345 RSASHA256 SHA256 abcdef"
404        );
405    }
406
407    #[test]
408    fn fwd_record_content() {
409        assert_eq!(
410            record_content("FWD", &json!({"forwarder": "1.1.1.1"})),
411            "1.1.1.1"
412        );
413    }
414
415    #[test]
416    fn unknown_type_falls_back_to_value_key() {
417        assert_eq!(
418            record_content("CUSTOM", &json!({"value": "some-data"})),
419            "some-data"
420        );
421    }
422
423    #[test]
424    fn unknown_type_falls_back_to_json() {
425        let data = json!({"field": "x"});
426        let result = record_content("MYSTERY", &data);
427        assert!(result.contains("field"));
428    }
429
430    #[test]
431    fn naptr_record_content() {
432        assert_eq!(
433            record_content(
434                "NAPTR",
435                &json!({
436                    "naptrOrder": 10,
437                    "naptrPreference": 20,
438                    "naptrFlags": "U",
439                    "naptrServices": "E2U+sip",
440                    "naptrRegexp": "!^.*$!sip:info@example.com!",
441                    "naptrReplacement": "."
442                })
443            ),
444            "10 20 \"U\" \"E2U+sip\" \"!^.*$!sip:info@example.com!\" ."
445        );
446    }
447
448    #[test]
449    fn record_content_is_case_insensitive() {
450        assert_eq!(
451            record_content("a", &json!({"ipAddress": "1.2.3.4"})),
452            record_content("A", &json!({"ipAddress": "1.2.3.4"}))
453        );
454    }
455}