1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::core::dns::records::{DsAlgorithm, RecordData};
11use crate::core::error::{Error, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "camelCase")]
19pub struct DnskeyData {
20 pub flags: u16,
21 pub protocol: u8,
23 pub algorithm: DsAlgorithm,
24 pub public_key: String,
26 pub computed_key_tag: u16,
27 pub dns_key_state: Option<String>,
29 pub is_ksk: Option<bool>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "camelCase")]
36pub struct RrsigData {
37 pub type_covered: String,
38 pub algorithm: DsAlgorithm,
39 pub labels: u8,
40 pub original_ttl: u32,
41 pub signature_expiration: String,
43 pub signature_inception: String,
45 pub key_tag: u16,
46 pub signer_name: String,
47 pub signature: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "camelCase")]
55pub struct NsecData {
56 pub next_domain_name: String,
57 pub types: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "camelCase")]
65pub struct Nsec3Data {
66 pub hash_algorithm: String,
67 pub flags: u8,
68 pub iterations: u16,
69 pub salt: String,
71 pub next_hashed_owner_name: String,
72 pub types: Vec<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78#[serde(tag = "type", rename_all = "UPPERCASE")]
79pub enum ReadOnlyRecordData {
80 Dnskey(DnskeyData),
81 Rrsig(RrsigData),
82 Nsec(NsecData),
83 Nsec3(Nsec3Data),
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
91#[serde(untagged)]
92pub enum AnyRecordData {
93 Writable(RecordData),
94 ReadOnly(ReadOnlyRecordData),
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "camelCase")]
102pub struct ZoneRecord {
103 pub name: String,
104 #[serde(rename = "type")]
105 pub record_type: String,
106 pub ttl: u32,
107 #[serde(default)]
108 pub disabled: bool,
109 #[serde(default)]
110 pub comments: String,
111 #[serde(default)]
112 pub expiry_ttl: u64,
113 #[serde(rename = "rData")]
114 pub data: serde_json::Value,
115 #[serde(skip)]
117 pub parsed: Option<AnyRecordData>,
118}
119
120impl ZoneRecord {
121 pub fn typed(&self) -> Option<AnyRecordData> {
125 if let Some(parsed) = &self.parsed {
126 return Some(parsed.clone());
127 }
128 parse_record_data(&self.record_type, &self.data)
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub struct ZoneInfo {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub id: Option<String>,
139 pub name: String,
140 #[serde(rename = "type")]
141 pub zone_type: String,
142 #[serde(default)]
143 pub disabled: bool,
144 pub dnssec_status: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149pub struct ZoneRecords {
150 pub zone: ZoneInfo,
151 pub records: Vec<ZoneRecord>,
152}
153
154#[derive(Debug, Clone)]
160pub struct ListRecordsResponse {
161 pub zones: Vec<ZoneRecords>,
162}
163
164impl serde::Serialize for ListRecordsResponse {
165 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
166 use serde::ser::SerializeMap;
167 match self.zones.as_slice() {
168 [single] => {
169 let mut map = s.serialize_map(Some(2))?;
170 map.serialize_entry("zone", &single.zone)?;
171 map.serialize_entry("records", &single.records)?;
172 map.end()
173 }
174 _ => {
175 let mut map = s.serialize_map(Some(1))?;
176 map.serialize_entry("zones", &self.zones)?;
177 map.end()
178 }
179 }
180 }
181}
182
183impl<'de> serde::Deserialize<'de> for ListRecordsResponse {
184 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
185 #[derive(Deserialize)]
186 #[serde(untagged)]
187 enum Repr {
188 Multi {
189 zones: Vec<ZoneRecords>,
190 },
191 Single {
192 zone: ZoneInfo,
193 records: Vec<ZoneRecord>,
194 },
195 }
196 match Repr::deserialize(d)? {
197 Repr::Multi { zones } => Ok(Self { zones }),
198 Repr::Single { zone, records } => Ok(Self::single(zone, records)),
199 }
200 }
201}
202
203impl ListRecordsResponse {
204 pub fn single(zone: ZoneInfo, records: Vec<ZoneRecord>) -> Self {
206 Self {
207 zones: vec![ZoneRecords { zone, records }],
208 }
209 }
210
211 pub fn from_value(value: &serde_json::Value) -> Result<Self> {
214 let response = value
215 .get("response")
216 .ok_or_else(|| Error::parse("list_records response missing 'response' key"))?;
217
218 let mut zone: ZoneInfo = serde_json::from_value(
219 response
220 .get("zone")
221 .ok_or_else(|| Error::parse("list_records response missing 'response.zone'"))?
222 .clone(),
223 )
224 .map_err(|e| Error::parse(format!("could not deserialize zone info: {e}")))?;
225 if zone.id.is_none() {
226 zone.id = Some(zone.name.clone());
227 }
228
229 let raw_records = response
230 .get("records")
231 .and_then(|r| r.as_array())
232 .ok_or_else(|| {
233 Error::parse("list_records response missing 'response.records' array")
234 })?;
235
236 let records = raw_records
237 .iter()
238 .filter_map(|r| {
239 let mut record: ZoneRecord = serde_json::from_value(r.clone()).ok()?;
240 record.parsed = parse_record_data(&record.record_type, &record.data);
241 Some(record)
242 })
243 .collect();
244
245 Ok(Self::single(zone, records))
246 }
247}
248
249fn parse_record_data(record_type: &str, rdata: &serde_json::Value) -> Option<AnyRecordData> {
250 let mut tagged = rdata.clone();
252 if let Some(obj) = tagged.as_object_mut() {
253 obj.insert(
254 "type".into(),
255 serde_json::Value::String(record_type.to_uppercase()),
256 );
257 }
258
259 if let Ok(w) = serde_json::from_value::<RecordData>(tagged.clone()) {
261 return Some(AnyRecordData::Writable(w));
262 }
263 if let Ok(ro) = serde_json::from_value::<ReadOnlyRecordData>(tagged) {
264 return Some(AnyRecordData::ReadOnly(ro));
265 }
266 None
267}
268
269#[cfg(test)]
272mod tests {
273 use super::*;
274 use rstest::{fixture, rstest};
275 use serde_json::json;
276
277 #[fixture]
280 fn zone_json() -> serde_json::Value {
281 json!({ "name": "example.com", "type": "Primary", "disabled": false })
282 }
283
284 #[fixture]
285 fn a_record_json() -> serde_json::Value {
286 json!({
287 "name": "www",
288 "type": "A",
289 "ttl": 3600,
290 "disabled": false,
291 "comments": "",
292 "rData": { "ipAddress": "1.2.3.4" }
293 })
294 }
295
296 #[fixture]
297 fn rrsig_record_json() -> serde_json::Value {
298 json!({
299 "name": "@",
300 "type": "RRSIG",
301 "ttl": 86400,
302 "disabled": false,
303 "comments": "",
304 "rData": {
305 "typeCovered": "A",
306 "algorithm": "ECDSAP256SHA256",
307 "labels": 2,
308 "originalTtl": 3600,
309 "signatureExpiration": "20261231000000",
310 "signatureInception": "20260101000000",
311 "keyTag": 12345,
312 "signerName": "example.com",
313 "signature": "abc123=="
314 }
315 })
316 }
317
318 #[fixture]
319 fn dnskey_record_json() -> serde_json::Value {
320 json!({
321 "name": "@",
322 "type": "DNSKEY",
323 "ttl": 86400,
324 "disabled": false,
325 "comments": "",
326 "rData": {
327 "flags": 257,
328 "protocol": 3,
329 "algorithm": "ECDSAP256SHA256",
330 "publicKey": "base64key==",
331 "computedKeyTag": 12345,
332 "dnsKeyState": "Active",
333 "isKsk": true
334 }
335 })
336 }
337
338 fn wrap_response(
339 zone: serde_json::Value,
340 records: Vec<serde_json::Value>,
341 ) -> serde_json::Value {
342 json!({ "status": "ok", "response": { "zone": zone, "records": records } })
343 }
344
345 #[rstest]
348 fn parses_zone_info(zone_json: serde_json::Value) {
349 let resp = wrap_response(zone_json, vec![]);
350 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
351 assert_eq!(result.zones.len(), 1);
352 assert_eq!(result.zones[0].zone.name, "example.com");
353 assert_eq!(result.zones[0].zone.zone_type, "Primary");
354 assert!(!result.zones[0].zone.disabled);
355 }
356
357 #[rstest]
358 fn empty_records_list(zone_json: serde_json::Value) {
359 let resp = wrap_response(zone_json, vec![]);
360 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
361 assert!(result.zones[0].records.is_empty());
362 }
363
364 #[rstest]
365 fn a_record_parsed_as_writable(zone_json: serde_json::Value, a_record_json: serde_json::Value) {
366 let resp = wrap_response(zone_json, vec![a_record_json]);
367 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
368
369 let records = &result.zones[0].records;
370 assert_eq!(records.len(), 1);
371 let record = &records[0];
372 assert_eq!(record.record_type, "A");
373 assert_eq!(record.ttl, 3600);
374 assert_eq!(record.name, "www");
375
376 match &record.parsed {
377 Some(AnyRecordData::Writable(RecordData::A { ip })) => {
378 assert_eq!(ip.to_string(), "1.2.3.4");
379 }
380 other => panic!("expected Writable(A), got {other:?}"),
381 }
382 }
383
384 #[rstest]
385 fn rrsig_parsed_as_read_only(
386 zone_json: serde_json::Value,
387 rrsig_record_json: serde_json::Value,
388 ) {
389 let resp = wrap_response(zone_json, vec![rrsig_record_json]);
390 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
391
392 match &result.zones[0].records[0].parsed {
393 Some(AnyRecordData::ReadOnly(ReadOnlyRecordData::Rrsig(data))) => {
394 assert_eq!(data.type_covered, "A");
395 assert_eq!(data.key_tag, 12345);
396 assert_eq!(data.signer_name, "example.com");
397 }
398 other => panic!("expected ReadOnly(Rrsig), got {other:?}"),
399 }
400 }
401
402 #[rstest]
403 fn dnskey_parsed_as_read_only(
404 zone_json: serde_json::Value,
405 dnskey_record_json: serde_json::Value,
406 ) {
407 let resp = wrap_response(zone_json, vec![dnskey_record_json]);
408 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
409
410 match &result.zones[0].records[0].parsed {
411 Some(AnyRecordData::ReadOnly(ReadOnlyRecordData::Dnskey(data))) => {
412 assert_eq!(data.flags, 257);
413 assert_eq!(data.computed_key_tag, 12345);
414 assert_eq!(data.dns_key_state.as_deref(), Some("Active"));
415 assert_eq!(data.is_ksk, Some(true));
416 }
417 other => panic!("expected ReadOnly(Dnskey), got {other:?}"),
418 }
419 }
420
421 #[rstest]
422 fn unknown_type_produces_none_parsed(zone_json: serde_json::Value) {
423 let record = json!({
424 "name": "weird",
425 "type": "NEWTYPE99",
426 "ttl": 300,
427 "rData": { "someField": "someValue" }
428 });
429 let resp = wrap_response(zone_json, vec![record]);
430 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
431 assert!(
432 result.zones[0].records[0].parsed.is_none(),
433 "unknown type should produce None"
434 );
435 }
436
437 #[rstest]
438 fn mixed_records_parse_correctly(
439 zone_json: serde_json::Value,
440 a_record_json: serde_json::Value,
441 rrsig_record_json: serde_json::Value,
442 ) {
443 let unknown = json!({ "name": "x", "type": "MYSTERY", "ttl": 60, "rData": {} });
444 let resp = wrap_response(zone_json, vec![a_record_json, rrsig_record_json, unknown]);
445 let result = ListRecordsResponse::from_value(&resp).expect("should parse");
446
447 let records = &result.zones[0].records;
448 assert_eq!(records.len(), 3);
449 assert!(matches!(
450 records[0].parsed,
451 Some(AnyRecordData::Writable(_))
452 ));
453 assert!(matches!(
454 records[1].parsed,
455 Some(AnyRecordData::ReadOnly(_))
456 ));
457 assert!(records[2].parsed.is_none());
458 }
459
460 #[rstest]
463 fn missing_response_key_returns_parse_error() {
464 let bad = json!({ "status": "ok" });
465 let err = ListRecordsResponse::from_value(&bad).unwrap_err();
466 assert!(
467 matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("'response'"))
468 );
469 }
470
471 #[rstest]
472 fn missing_zone_key_returns_parse_error() {
473 let bad = json!({ "status": "ok", "response": { "records": [] } });
474 let err = ListRecordsResponse::from_value(&bad).unwrap_err();
475 assert!(
476 matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("zone"))
477 );
478 }
479
480 #[rstest]
481 fn missing_records_key_returns_parse_error(zone_json: serde_json::Value) {
482 let bad = json!({ "status": "ok", "response": { "zone": zone_json } });
483 let err = ListRecordsResponse::from_value(&bad).unwrap_err();
484 assert!(
485 matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("records"))
486 );
487 }
488
489 #[rstest]
490 #[case(json!({}))]
491 #[case(json!(null))]
492 #[case(json!([]))]
493 fn empty_or_null_json_returns_parse_error(#[case] input: serde_json::Value) {
494 assert!(ListRecordsResponse::from_value(&input).is_err());
495 }
496
497 #[rstest]
498 fn skips_malformed_records_rather_than_failing(
499 zone_json: serde_json::Value,
500 a_record_json: serde_json::Value,
501 ) {
502 let bad_record = json!({ "name": "bad", "ttl": 300, "rData": {} });
503 let resp = wrap_response(zone_json, vec![bad_record, a_record_json]);
504 let result = ListRecordsResponse::from_value(&resp).expect("should parse overall response");
505 let records = &result.zones[0].records;
506 assert_eq!(records.len(), 1);
507 assert_eq!(records[0].record_type, "A");
508 }
509
510 #[rstest]
513 fn record_disabled_defaults_to_false(zone_json: serde_json::Value) {
514 let record = json!({
515 "name": "test", "type": "A", "ttl": 300,
516 "rData": { "ipAddress": "10.0.0.1" }
517 });
518 let resp = wrap_response(zone_json, vec![record]);
519 let result = ListRecordsResponse::from_value(&resp).unwrap();
520 assert!(!result.zones[0].records[0].disabled);
521 }
522
523 #[rstest]
524 fn record_comments_defaults_to_empty(zone_json: serde_json::Value) {
525 let record = json!({
526 "name": "test", "type": "A", "ttl": 300,
527 "rData": { "ipAddress": "10.0.0.1" }
528 });
529 let resp = wrap_response(zone_json, vec![record]);
530 let result = ListRecordsResponse::from_value(&resp).unwrap();
531 assert_eq!(result.zones[0].records[0].comments, "");
532 }
533
534 #[rstest]
537 fn single_wraps_zone_and_records_in_one_entry(zone_json: serde_json::Value) {
538 let zone: ZoneInfo = serde_json::from_value(zone_json).unwrap();
539 let result = ListRecordsResponse::single(zone, vec![]);
540 assert_eq!(result.zones.len(), 1);
541 assert_eq!(result.zones[0].zone.name, "example.com");
542 assert!(result.zones[0].records.is_empty());
543 }
544
545 fn make_zone(name: &str) -> ZoneInfo {
548 ZoneInfo {
549 id: None,
550 name: name.to_string(),
551 zone_type: "Primary".to_string(),
552 disabled: false,
553 dnssec_status: None,
554 }
555 }
556
557 #[test]
558 fn single_zone_serializes_flat() {
559 let resp = ListRecordsResponse::single(make_zone("example.com"), vec![]);
560 let v = serde_json::to_value(&resp).unwrap();
561 assert!(v.get("zone").is_some(), "should have top-level 'zone'");
562 assert!(
563 v.get("records").is_some(),
564 "should have top-level 'records'"
565 );
566 assert!(v.get("zones").is_none(), "should NOT have 'zones' wrapper");
567 assert_eq!(v["zone"]["name"], "example.com");
568 }
569
570 #[test]
571 fn multi_zone_serializes_with_zones_array() {
572 let resp = ListRecordsResponse {
573 zones: vec![
574 ZoneRecords {
575 zone: make_zone("a.example.com"),
576 records: vec![],
577 },
578 ZoneRecords {
579 zone: make_zone("b.example.com"),
580 records: vec![],
581 },
582 ],
583 };
584 let v = serde_json::to_value(&resp).unwrap();
585 assert!(v.get("zones").is_some(), "should have 'zones' array");
586 assert!(v.get("zone").is_none(), "should NOT have top-level 'zone'");
587 assert_eq!(v["zones"].as_array().unwrap().len(), 2);
588 }
589
590 #[test]
591 fn single_zone_round_trips_through_serde() {
592 let original = ListRecordsResponse::single(make_zone("example.com"), vec![]);
593 let json = serde_json::to_value(&original).unwrap();
594 let restored: ListRecordsResponse = serde_json::from_value(json).unwrap();
595 assert_eq!(restored.zones.len(), 1);
596 assert_eq!(restored.zones[0].zone.name, "example.com");
597 }
598
599 #[test]
600 fn multi_zone_round_trips_through_serde() {
601 let original = ListRecordsResponse {
602 zones: vec![
603 ZoneRecords {
604 zone: make_zone("a.example.com"),
605 records: vec![],
606 },
607 ZoneRecords {
608 zone: make_zone("b.example.com"),
609 records: vec![],
610 },
611 ],
612 };
613 let json = serde_json::to_value(&original).unwrap();
614 let restored: ListRecordsResponse = serde_json::from_value(json).unwrap();
615 assert_eq!(restored.zones.len(), 2);
616 assert_eq!(restored.zones[0].zone.name, "a.example.com");
617 assert_eq!(restored.zones[1].zone.name, "b.example.com");
618 }
619}