Skip to main content

dinio_sdk/models/
zone_file.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Error, Result};
7
8/// File format of a zone export or import (`?format=`).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum ZoneFileFormat {
12    /// The dinio-zone document ([`ZoneDocument`]): records with geo routing, balance
13    /// ratio, host health, geo bans and health checks, plus redirects. Names relative to
14    /// the zone, so it works as a template for another zone. Core's default.
15    #[default]
16    Json,
17    /// A BIND zone file: name, ttl, type and data only.
18    Bind,
19    /// CSV with name, ttl, type and data only.
20    Csv,
21}
22
23impl ZoneFileFormat {
24    /// The value Core takes (`json`, `bind`, `csv`).
25    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    /// The format a file name implies: `.json`; `.zone`, `.bind`, `.db`; `.csv` (case does
34    /// not matter). `None` for anything else.
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use dinio_sdk::models::ZoneFileFormat;
40    ///
41    /// assert_eq!(ZoneFileFormat::from_file_name("example.com.zone"), Some(ZoneFileFormat::Bind));
42    /// assert_eq!(ZoneFileFormat::from_file_name("export.CSV"), Some(ZoneFileFormat::Csv));
43    /// assert_eq!(ZoneFileFormat::from_file_name("records.txt"), None);
44    /// ```
45    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    /// The extension Core gives an exported file (`json`, `zone`, `csv`).
56    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    /// `json`, `bind` (or `zone`), `csv`; case-insensitive.
75    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/// The dinio-zone document (`format: "dinio-zone"`, `version: 1`): Core's own export
88/// format, and what [`Zones::discover`](crate::api::Zones::discover) proposes.
89///
90/// Names are relative to the zone (`@` is the apex; in-zone names inside data have no
91/// trailing dot, absolute names keep it). Free text (TXT, redirect locations, health
92/// check URLs) is never rewritten. Left out by Core: the apex SOA/NS, values Core keeps
93/// (`managed_by`) and certificates. Enum values are kept as strings, like the resource
94/// models.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct ZoneDocument {
97    /// Always `dinio-zone`.
98    pub format: String,
99    /// Document version, `1`.
100    pub version: u32,
101    /// The zone it came from; informational, ignored on import.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub zone: Option<String>,
104    /// Export time, Unix seconds.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub exported_at: Option<i64>,
107    /// Record values, one per entry; at most 10 000.
108    pub records: Vec<ZoneDocRecord>,
109    /// Redirects of the zone.
110    #[serde(default)]
111    pub redirects: Vec<ZoneDocRedirect>,
112}
113
114/// One record value of a [`ZoneDocument`].
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct ZoneDocRecord {
117    /// Owner name relative to the zone (`@`, `www`).
118    pub name: String,
119    /// Record type (`A`, `MX`).
120    #[serde(rename = "type")]
121    pub record_type: String,
122    /// TTL in seconds. On import an RRset already in the zone keeps its own.
123    pub ttl: u32,
124    /// Record data, in-zone names relative.
125    pub data: String,
126    /// As `RecordValue::geo_routing`.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub geo_routing: Option<bool>,
129    /// As `RecordValue::balance_ratio`.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub balance_ratio: Option<u32>,
132    /// As `RecordValue::host_health`; omitted for `None`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub host_health: Option<ZoneDocHostHealth>,
135    /// Geo bans of the value.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub geo_bans: Option<Vec<ZoneDocGeoBan>>,
138    /// Health check of the value, without status.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub health_check: Option<ZoneDocHealthCheck>,
141}
142
143/// `host_health` of a [`ZoneDocRecord`]. Core exports a mode label; documents exported
144/// before 2026-10-28 carry a boolean, which Core still imports.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(untagged)]
147pub enum ZoneDocHostHealth {
148    /// `Up`, `Down` or `Auto` (see [`HostHealthMode`](crate::models::HostHealthMode); kept
149    /// as a string for forward compatibility). `Auto` needs a `health_check`.
150    Mode(String),
151    /// An old document's value: Core imports a record with an enabled health check as
152    /// `Auto`, otherwise `true`/`false` as `Up`/`Down`.
153    Legacy(bool),
154}
155
156/// A geo ban inside a [`ZoneDocRecord`].
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ZoneDocGeoBan {
159    /// `Region`, `Subregion` or `Country`.
160    pub level: String,
161    /// Code at that level; must be in the geo catalog.
162    pub code: u16,
163}
164
165/// A health check inside a [`ZoneDocRecord`]: `POST /health-checks` without
166/// `record_id`, same limits.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct ZoneDocHealthCheck {
169    /// `http`, `tcp` or `ping`.
170    #[serde(rename = "type")]
171    pub check_type: String,
172    /// Label.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub name: Option<String>,
175    /// Type-specific settings, as `HealthCheck::config`.
176    pub config: serde_json::Value,
177    /// Seconds between runs.
178    pub interval: u32,
179    /// Seconds per attempt.
180    pub timeout: u32,
181    /// Extra attempts before a failure counts.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub retries: Option<u32>,
184    /// Seconds between attempts.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub retry_interval: Option<u32>,
187}
188
189/// A redirect inside a [`ZoneDocument`].
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct ZoneDocRedirect {
192    /// Host name relative to the zone.
193    pub name: String,
194    /// HTTP status (301, 302, 307, 308).
195    pub code: u16,
196    /// Target URL.
197    pub location: String,
198    /// `Auto`, `Manual` or `None`.
199    pub tls: String,
200}
201
202/// What an import did, or would do ([`Zones::import`](crate::api::Zones::import)).
203///
204/// Import is a merge: it adds what the zone lacks and never changes or deletes anything.
205/// Records go in one transaction (any record error writes none of them); redirects
206/// afterwards, each on its own.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct ImportReport {
209    /// Whether this was a dry run.
210    pub dry_run: bool,
211    /// Whether the records were written: `false` after a dry run or a record error.
212    pub committed: bool,
213    /// The record part.
214    pub records: ImportRecordsReport,
215    /// The redirect part.
216    pub redirects: ImportRedirectsReport,
217}
218
219impl ImportReport {
220    /// Whether any record or redirect failed.
221    pub fn has_errors(&self) -> bool {
222        !self.records.errors.is_empty() || !self.redirects.errors.is_empty()
223    }
224}
225
226/// Records of an [`ImportReport`].
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct ImportRecordsReport {
229    /// Created, or would be without a commit.
230    pub created: u32,
231    /// Already in the zone, or repeated in the file.
232    pub duplicates: u32,
233    /// Left out on purpose (e.g. SOA, apex NS: the zone keeps Core's).
234    pub skipped: Vec<ImportIssue>,
235    /// Imported, with a remark (e.g. a ttl that did not apply).
236    pub warnings: Vec<ImportIssue>,
237    /// Refused; with any of these, no record is written.
238    pub errors: Vec<ImportIssue>,
239}
240
241/// Redirects of an [`ImportReport`] (dinio-zone documents only).
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct ImportRedirectsReport {
244    /// Host names of the redirects created.
245    pub created: Vec<String>,
246    /// Redirects that failed; the others stay.
247    pub errors: Vec<ImportIssue>,
248}
249
250/// One remark of an [`ImportReport`].
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct ImportIssue {
253    /// Where: the line for `bind`/`csv` (from 1; 0 is the whole file), the array index
254    /// for `json` (from 0).
255    #[serde(rename = "ref")]
256    pub reference: u32,
257    /// Owner name, when known.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub name: Option<String>,
260    /// Record type, when known.
261    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
262    pub record_type: Option<String>,
263    /// What is wrong or was done.
264    pub message: String,
265}
266
267/// What [`Zones::discover`](crate::api::Zones::discover) found.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct Discovery {
270    /// The nameserver host names asked.
271    pub nameservers: Vec<String>,
272    /// The address that answered.
273    pub server: String,
274    /// Whether the zone answers any name (a wildcard); Core then proposes `*`.
275    pub wildcard: bool,
276    /// DNS questions asked.
277    pub queries: u32,
278    /// Remarks for the user (e.g. names skipped for lack of time).
279    #[serde(default)]
280    pub notes: Vec<String>,
281    /// The proposal: records only, to review, trim and import. Only A/AAAA carry their
282    /// real ttl; the others get the zone default.
283    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}