1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum ZoneFileFormat {
12 #[default]
16 Json,
17 Bind,
19 Csv,
21}
22
23impl ZoneFileFormat {
24 pub fn as_wire(self) -> &'static str {
26 match self {
27 Self::Json => "json",
28 Self::Bind => "bind",
29 Self::Csv => "csv",
30 }
31 }
32
33 pub fn from_file_name(name: &str) -> Option<Self> {
46 let (_, extension) = name.rsplit_once('.')?;
47 match extension.to_ascii_lowercase().as_str() {
48 "json" => Some(Self::Json),
49 "zone" | "bind" | "db" => Some(Self::Bind),
50 "csv" => Some(Self::Csv),
51 _ => None,
52 }
53 }
54
55 pub fn extension(self) -> &'static str {
57 match self {
58 Self::Json => "json",
59 Self::Bind => "zone",
60 Self::Csv => "csv",
61 }
62 }
63}
64
65impl fmt::Display for ZoneFileFormat {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str(self.as_wire())
68 }
69}
70
71impl FromStr for ZoneFileFormat {
72 type Err = Error;
73
74 fn from_str(text: &str) -> Result<Self> {
76 match text.trim().to_ascii_lowercase().as_str() {
77 "json" => Ok(Self::Json),
78 "bind" | "zone" => Ok(Self::Bind),
79 "csv" => Ok(Self::Csv),
80 _ => Err(Error::InvalidInput(format!(
81 "unknown zone file format {text:?} (json, bind or csv)"
82 ))),
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct ZoneDocument {
97 pub format: String,
99 pub version: u32,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub zone: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub exported_at: Option<i64>,
107 pub records: Vec<ZoneDocRecord>,
109 #[serde(default)]
111 pub redirects: Vec<ZoneDocRedirect>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct ZoneDocRecord {
117 pub name: String,
119 #[serde(rename = "type")]
121 pub record_type: String,
122 pub ttl: u32,
124 pub data: String,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub geo_routing: Option<bool>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub balance_ratio: Option<u32>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub host_health: Option<ZoneDocHostHealth>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub geo_bans: Option<Vec<ZoneDocGeoBan>>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub health_check: Option<ZoneDocHealthCheck>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(untagged)]
147pub enum ZoneDocHostHealth {
148 Mode(String),
151 Legacy(bool),
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ZoneDocGeoBan {
159 pub level: String,
161 pub code: u16,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct ZoneDocHealthCheck {
169 #[serde(rename = "type")]
171 pub check_type: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub name: Option<String>,
175 pub config: serde_json::Value,
177 pub interval: u32,
179 pub timeout: u32,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub retries: Option<u32>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub retry_interval: Option<u32>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct ZoneDocRedirect {
192 pub name: String,
194 pub code: u16,
196 pub location: String,
198 pub tls: String,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct ImportReport {
209 pub dry_run: bool,
211 pub committed: bool,
213 pub records: ImportRecordsReport,
215 pub redirects: ImportRedirectsReport,
217}
218
219impl ImportReport {
220 pub fn has_errors(&self) -> bool {
222 !self.records.errors.is_empty() || !self.redirects.errors.is_empty()
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct ImportRecordsReport {
229 pub created: u32,
231 pub duplicates: u32,
233 pub skipped: Vec<ImportIssue>,
235 pub warnings: Vec<ImportIssue>,
237 pub errors: Vec<ImportIssue>,
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct ImportRedirectsReport {
244 pub created: Vec<String>,
246 pub errors: Vec<ImportIssue>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct ImportIssue {
253 #[serde(rename = "ref")]
256 pub reference: u32,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub name: Option<String>,
260 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
262 pub record_type: Option<String>,
263 pub message: String,
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct Discovery {
270 pub nameservers: Vec<String>,
272 pub server: String,
274 pub wildcard: bool,
276 pub queries: u32,
278 #[serde(default)]
280 pub notes: Vec<String>,
281 pub document: ZoneDocument,
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn format_names() {
292 assert_eq!(
293 "BIND".parse::<ZoneFileFormat>().unwrap(),
294 ZoneFileFormat::Bind
295 );
296 assert_eq!(
297 "zone".parse::<ZoneFileFormat>().unwrap(),
298 ZoneFileFormat::Bind
299 );
300 assert!("xml".parse::<ZoneFileFormat>().is_err());
301 assert_eq!(ZoneFileFormat::from_file_name("noextension"), None);
302 assert_eq!(ZoneFileFormat::Bind.extension(), "zone");
303 }
304
305 #[test]
306 fn document_round_trips() {
307 let wire = serde_json::json!({
308 "format": "dinio-zone", "version": 1, "zone": "example.com",
309 "records": [
310 { "name": "www", "type": "A", "ttl": 300, "data": "192.0.2.1", "host_health": "Auto",
311 "geo_bans": [{ "level": "Country", "code": 191 }],
312 "health_check": { "type": "tcp", "config": { "host": "192.0.2.1", "port": 80 },
313 "interval": 60, "timeout": 5 } },
314 { "name": "@", "type": "MX", "ttl": 3600, "data": "10 mail" }
315 ],
316 "redirects": [{ "name": "go", "code": 302, "location": "https://example.org/", "tls": "None" }]
317 });
318 let doc: ZoneDocument = serde_json::from_value(wire.clone()).unwrap();
319 assert_eq!(doc.records[1].record_type, "MX");
320 assert_eq!(serde_json::to_value(&doc).unwrap(), wire);
321 }
322
323 #[test]
324 fn old_document_host_health_parses() {
325 let record: ZoneDocRecord = serde_json::from_value(serde_json::json!({
326 "name": "www", "type": "A", "ttl": 300, "data": "192.0.2.1", "host_health": false,
327 "health_check": { "type": "ping", "config": { "address": "192.0.2.1" },
328 "enabled": false, "interval": 60, "timeout": 5 }
329 }))
330 .unwrap();
331 assert_eq!(record.host_health, Some(ZoneDocHostHealth::Legacy(false)));
332 }
333
334 #[test]
335 fn report_deserializes() {
336 let report: ImportReport = serde_json::from_value(serde_json::json!({
337 "dry_run": true, "committed": false,
338 "records": { "created": 3, "duplicates": 1,
339 "skipped": [{ "ref": 2, "name": "example.com", "type": "SOA", "message": "zone keeps its SOA" }],
340 "warnings": [], "errors": [] },
341 "redirects": { "created": [], "errors": [] }
342 }))
343 .unwrap();
344 assert_eq!(report.records.skipped[0].reference, 2);
345 assert!(!report.has_errors());
346 }
347}