Skip to main content

dnslib/control_plane/
sync.rs

1//! Record-level sync between two configured DNS servers.
2//!
3//! `dns sync` reads records from a source server, optionally rewrites IP
4//! addresses on A/AAAA records (e.g. external → internal), and writes the
5//! difference to a destination server. It is vendor-neutral: it goes through
6//! the shared `core::dns` traits, so any pair of supported vendors can sync.
7//!
8//! Sync is **additive** — it adds records the destination is missing and
9//! updates record sets whose values differ, but never prunes whole names that
10//! exist only on the destination. It is **dry-run by default**; `--apply`
11//! commits the changes.
12
13use std::collections::HashMap;
14use std::net::IpAddr;
15
16use crate::control_plane::config::AppConfig;
17use crate::core::dns::records::RecordData;
18use crate::core::dns::records::query::{extract_zone_names, resolve_fqdn};
19use crate::core::dns::responses::{AnyRecordData, ListRecordsResponse};
20use crate::core::dns::service::{ListRecordsOptions, RecordWrite, ZoneRead};
21use crate::core::error::{Error, Result};
22use crate::vendors::runtime::VendorClient;
23
24/// TTL used when a source record reports a TTL of 0 (some vendors do not
25/// expose per-record TTLs).
26const DEFAULT_TTL: u32 = 3600;
27
28/// One record to be written to (or removed from) the destination.
29#[derive(Debug, Clone)]
30struct PlannedRecord {
31    /// Fully-qualified record name.
32    fqdn: String,
33    /// Uppercase record type, e.g. `A`.
34    rtype: String,
35    ttl: u32,
36    record: RecordData,
37}
38
39/// The computed difference for one zone.
40#[derive(Debug, Default)]
41struct Diff {
42    adds: Vec<PlannedRecord>,
43    deletes: Vec<PlannedRecord>,
44    unchanged: usize,
45    /// Destination records whose name+type is absent from the source — left
46    /// untouched because sync is additive.
47    untouched: usize,
48}
49
50/// The plan for one zone, ready to display or apply.
51#[derive(Debug)]
52struct ZonePlan {
53    zone: String,
54    adds: Vec<PlannedRecord>,
55    deletes: Vec<PlannedRecord>,
56    unchanged: usize,
57    untouched: usize,
58    /// Source records that cannot be synced (SOA, DNSSEC, disabled, unknown).
59    skipped: usize,
60}
61
62#[derive(Debug, Clone, serde::Serialize)]
63pub struct SyncApplySummary {
64    pub applied: usize,
65    pub failures: usize,
66}
67
68/// Run a record sync.
69///
70/// `profile` selects a named `[[sync]]` profile from the config; `from`, `to`,
71/// `zones` and `maps` are CLI overrides that take precedence over the profile.
72///
73/// # Errors
74///
75/// Returns an error if the config, servers, zones, or IP mappings cannot be
76/// resolved, or — when `apply` is set — if any record write fails.
77#[allow(clippy::too_many_arguments)]
78pub async fn run_sync(
79    app_config: Option<&AppConfig>,
80    profile: Option<&str>,
81    from: Option<&str>,
82    to: Option<&str>,
83    zones: &[String],
84    maps: &[String],
85    apply: bool,
86    json: bool,
87) -> Result<()> {
88    let (from_id, to_id, plans) =
89        build_sync_plan(app_config, profile, from, to, zones, maps).await?;
90
91    if json {
92        let out = sync_plan_json(&from_id, &to_id, &plans, apply);
93        let pretty = serde_json::to_string_pretty(&out)
94            .map_err(|e| Error::parse(format!("could not serialise sync plan: {e}")))?;
95        println!("{pretty}");
96    } else {
97        render_table(&from_id, &to_id, &plans, apply);
98    }
99
100    let has_changes = plans
101        .iter()
102        .any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
103    if !apply || !has_changes {
104        return Ok(());
105    }
106
107    let summary = apply_plans(
108        &VendorClient::from_server(
109            app_config
110                .and_then(|cfg| cfg.selected_server(Some(&to_id)).ok())
111                .ok_or_else(|| Error::config("sync destination server disappeared"))?,
112        )?,
113        &plans,
114    )
115    .await?;
116    println!("\nApplied {} change(s).", summary.applied);
117    Ok(())
118}
119
120#[allow(clippy::too_many_arguments)]
121pub async fn run_sync_json(
122    app_config: Option<&AppConfig>,
123    profile: Option<&str>,
124    from: Option<&str>,
125    to: Option<&str>,
126    zones: &[String],
127    maps: &[String],
128    apply: bool,
129) -> Result<serde_json::Value> {
130    let (from_id, to_id, plans) =
131        build_sync_plan(app_config, profile, from, to, zones, maps).await?;
132    let mut out = sync_plan_json(&from_id, &to_id, &plans, apply);
133
134    let has_changes = plans
135        .iter()
136        .any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
137    if apply && has_changes {
138        let cfg = app_config.expect("build_sync_plan already required config");
139        let to_server = cfg.selected_server(Some(&to_id))?;
140        let summary = apply_plans(&VendorClient::from_server(to_server)?, &plans).await?;
141        out["apply_summary"] = serde_json::to_value(summary)
142            .map_err(|e| Error::parse(format!("could not serialise sync summary: {e}")))?;
143    }
144
145    Ok(out)
146}
147
148#[allow(clippy::too_many_arguments)]
149async fn build_sync_plan(
150    app_config: Option<&AppConfig>,
151    profile: Option<&str>,
152    from: Option<&str>,
153    to: Option<&str>,
154    zones: &[String],
155    maps: &[String],
156) -> Result<(String, String, Vec<ZonePlan>)> {
157    let Some(cfg) = app_config else {
158        return Err(Error::config(
159            "sync requires a config file defining the source and destination servers",
160        ));
161    };
162
163    // Resolve the profile, if one was named.
164    let profile = match profile {
165        Some(name) => Some(
166            cfg.sync
167                .iter()
168                .find(|p| p.name.eq_ignore_ascii_case(name))
169                .ok_or_else(|| {
170                    Error::config(format!(
171                        "config does not define a sync profile named '{name}'"
172                    ))
173                })?,
174        ),
175        None => None,
176    };
177
178    // From/to: CLI flag wins, then the profile.
179    let from_id = from
180        .or_else(|| profile.map(|p| p.from.as_str()))
181        .ok_or_else(|| {
182            Error::parse("sync requires a source server: name a profile or pass --from")
183        })?;
184    let to_id = to
185        .or_else(|| profile.map(|p| p.to.as_str()))
186        .ok_or_else(|| {
187            Error::parse("sync requires a destination server: name a profile or pass --to")
188        })?;
189
190    // IP map: profile entries first, then CLI --map (which overrides).
191    let mut ip_map: HashMap<IpAddr, IpAddr> = HashMap::new();
192    if let Some(p) = profile {
193        for (src, dst) in &p.ip_map {
194            let (s, d) = parse_ip_pair(&format!("{src}={dst}"))?;
195            ip_map.insert(s, d);
196        }
197    }
198    for spec in maps {
199        let (s, d) = parse_ip_pair(spec)?;
200        ip_map.insert(s, d);
201    }
202
203    let from_server = cfg.selected_server(Some(from_id))?;
204    let to_server = cfg.selected_server(Some(to_id))?;
205    let from_client = VendorClient::from_server(from_server)?;
206    let to_client = VendorClient::from_server(to_server)?;
207
208    // Zones: CLI wins, then the profile, then every zone on the source.
209    let zone_list: Vec<String> = if !zones.is_empty() {
210        zones.to_vec()
211    } else if let Some(p) = profile.filter(|p| !p.zones.is_empty()) {
212        p.zones.clone()
213    } else {
214        const PAGE_SIZE: u32 = 1000;
215        let mut page = 1;
216        let mut names = Vec::new();
217        loop {
218            let value = from_client.list_zones(page, PAGE_SIZE).await?;
219            let batch = extract_zone_names(&value);
220            let batch_len = batch.len();
221            names.extend(batch);
222            if batch_len < PAGE_SIZE as usize {
223                break;
224            }
225            page += 1;
226        }
227        if names.is_empty() {
228            return Err(Error::parse(format!(
229                "no zones found on source server '{from_id}'; specify one with --zone"
230            )));
231        }
232        names
233    };
234
235    let mut plans = Vec::with_capacity(zone_list.len());
236    for zone in &zone_list {
237        plans.push(plan_zone(&from_client, &to_client, zone, &ip_map).await?);
238    }
239
240    Ok((from_id.to_string(), to_id.to_string(), plans))
241}
242
243/// Build the sync plan for a single zone.
244async fn plan_zone(
245    from_client: &VendorClient,
246    to_client: &VendorClient,
247    zone: &str,
248    ip_map: &HashMap<IpAddr, IpAddr>,
249) -> Result<ZonePlan> {
250    plan_zone_with_clients(from_client, to_client, zone, ip_map).await
251}
252
253async fn plan_zone_with_clients<F, T>(
254    from_client: &F,
255    to_client: &T,
256    zone: &str,
257    ip_map: &HashMap<IpAddr, IpAddr>,
258) -> Result<ZonePlan>
259where
260    F: ZoneRead + ?Sized,
261    T: ZoneRead + ?Sized,
262{
263    let opts = ListRecordsOptions {
264        all_subdomains: true,
265        ..ListRecordsOptions::default()
266    };
267
268    let source = from_client
269        .list_records(zone, Some(zone), opts)
270        .await
271        .map_err(|e| Error::parse(format!("source: listing records for zone '{zone}': {e}")))?;
272    let dest = to_client
273        .list_records(zone, Some(zone), opts)
274        .await
275        .map_err(|e| {
276            Error::parse(format!(
277                "destination: listing records for zone '{zone}' \
278                 (does the zone exist on the destination?): {e}"
279            ))
280        })?;
281
282    let (source_records, skipped) = collect_records(&source, zone, Some(ip_map));
283    let (dest_records, _) = collect_records(&dest, zone, None);
284
285    let mut diff = diff_records(source_records, dest_records);
286    diff.adds.sort_by_key(sort_key);
287    diff.deletes.sort_by_key(sort_key);
288
289    Ok(ZonePlan {
290        zone: zone.to_string(),
291        adds: diff.adds,
292        deletes: diff.deletes,
293        unchanged: diff.unchanged,
294        untouched: diff.untouched,
295        skipped,
296    })
297}
298
299/// Turn a vendor record-list response into syncable [`PlannedRecord`]s,
300/// applying `ip_map` when one is supplied. Returns the records plus the count
301/// of records skipped because they are disabled or not syncable.
302fn collect_records(
303    response: &ListRecordsResponse,
304    zone: &str,
305    ip_map: Option<&HashMap<IpAddr, IpAddr>>,
306) -> (Vec<PlannedRecord>, usize) {
307    let mut out = Vec::new();
308    let mut skipped = 0;
309
310    for zone_records in &response.zones {
311        for record in &zone_records.records {
312            if record.disabled {
313                skipped += 1;
314                continue;
315            }
316            // Server-managed records (SOA, DNSSEC) and unknown types cannot be
317            // written through the record API.
318            let Some(AnyRecordData::Writable(rd)) = record.typed() else {
319                skipped += 1;
320                continue;
321            };
322            let rd = match ip_map {
323                Some(map) => apply_ip_map(rd, map),
324                None => rd,
325            };
326            let fqdn = resolve_fqdn(&record.name, Some(zone));
327            if fqdn.eq_ignore_ascii_case(zone) && rd.type_name() == "NS" {
328                skipped += 1;
329                continue;
330            }
331            out.push(PlannedRecord {
332                fqdn,
333                rtype: rd.type_name().to_string(),
334                ttl: if record.ttl == 0 {
335                    DEFAULT_TTL
336                } else {
337                    record.ttl
338                },
339                record: rd,
340            });
341        }
342    }
343
344    (out, skipped)
345}
346
347/// Compute the additive difference between source and destination records.
348///
349/// Records are grouped into sets by `(name, type)`. A set missing on the
350/// destination is added wholesale; a set present on both with differing values
351/// has its missing values added and its stale values removed. Sets that exist
352/// only on the destination are counted as `untouched` and never pruned.
353fn diff_records(source: Vec<PlannedRecord>, dest: Vec<PlannedRecord>) -> Diff {
354    let group = |records: Vec<PlannedRecord>| {
355        let mut groups: HashMap<(String, String), Vec<PlannedRecord>> = HashMap::new();
356        for r in records {
357            groups
358                .entry((r.fqdn.to_lowercase(), r.rtype.clone()))
359                .or_default()
360                .push(r);
361        }
362        groups
363    };
364
365    let source_groups = group(source);
366    let dest_groups = group(dest);
367
368    let mut diff = Diff::default();
369
370    // A record is "unchanged" only when its value AND TTL match the destination;
371    // otherwise it is added (and the stale destination value, if any, deleted)
372    // so source TTLs propagate.
373    let match_key = |r: &PlannedRecord| (canonical(&r.record), r.ttl);
374
375    for (key, src_recs) in &source_groups {
376        let dest_recs = dest_groups.get(key);
377        let dest_keys: Vec<(String, u32)> = dest_recs
378            .map(|recs| recs.iter().map(match_key).collect())
379            .unwrap_or_default();
380        let src_keys: Vec<(String, u32)> = src_recs.iter().map(match_key).collect();
381
382        for r in src_recs {
383            if dest_keys.contains(&match_key(r)) {
384                diff.unchanged += 1;
385            } else {
386                diff.adds.push(r.clone());
387            }
388        }
389        if let Some(dest_recs) = dest_recs {
390            for r in dest_recs {
391                if !src_keys.contains(&match_key(r)) {
392                    diff.deletes.push(r.clone());
393                }
394            }
395        }
396    }
397
398    diff.untouched = dest_groups
399        .iter()
400        .filter(|(key, _)| !source_groups.contains_key(*key))
401        .map(|(_, recs)| recs.len())
402        .sum();
403
404    diff
405}
406
407/// Apply the planned changes to the destination, reporting per-record outcomes.
408async fn apply_plans(to_client: &VendorClient, plans: &[ZonePlan]) -> Result<SyncApplySummary> {
409    apply_plans_with_client(to_client, plans).await
410}
411
412async fn apply_plans_with_client<C>(to_client: &C, plans: &[ZonePlan]) -> Result<SyncApplySummary>
413where
414    C: RecordWrite + ?Sized,
415{
416    let mut applied = 0;
417    let mut failures = 0;
418
419    for plan in plans {
420        // Add new values before removing stale ones, to minimise the window in
421        // which a name resolves to nothing.
422        let mut zone_add_failed = false;
423        for rec in &plan.adds {
424            match to_client
425                .add_record(&plan.zone, &rec.fqdn, rec.ttl, &rec.record)
426                .await
427            {
428                Ok(_) => applied += 1,
429                Err(e) => {
430                    failures += 1;
431                    zone_add_failed = true;
432                    eprintln!("  ! add {} {} failed: {e}", rec.fqdn, rec.rtype);
433                }
434            }
435        }
436        // Don't run destructive deletes for a zone whose additions failed —
437        // we might remove the only working copy of a record.
438        if zone_add_failed {
439            eprintln!(
440                "  ! skipping removals for zone '{}' because one or more additions failed",
441                plan.zone
442            );
443            continue;
444        }
445        for rec in &plan.deletes {
446            let params = rec.record.to_api_params();
447            match to_client
448                .delete_record(&plan.zone, &rec.fqdn, &params)
449                .await
450            {
451                Ok(_) => applied += 1,
452                Err(e) => {
453                    failures += 1;
454                    eprintln!("  ! remove {} {} failed: {e}", rec.fqdn, rec.rtype);
455                }
456            }
457        }
458    }
459
460    if failures > 0 {
461        return Err(Error::api(format!("{failures} sync change(s) failed")));
462    }
463    Ok(SyncApplySummary { applied, failures })
464}
465
466/// Rewrite an A/AAAA record's address through the IP map. Other record types
467/// and unmapped addresses pass through unchanged.
468fn apply_ip_map(record: RecordData, map: &HashMap<IpAddr, IpAddr>) -> RecordData {
469    match record {
470        RecordData::A { ip } => match map.get(&IpAddr::V4(ip)) {
471            Some(IpAddr::V4(mapped)) => RecordData::A { ip: *mapped },
472            _ => RecordData::A { ip },
473        },
474        RecordData::Aaaa { ip } => match map.get(&IpAddr::V6(ip)) {
475            Some(IpAddr::V6(mapped)) => RecordData::Aaaa { ip: *mapped },
476            _ => RecordData::Aaaa { ip },
477        },
478        other => other,
479    }
480}
481
482/// Parse a `SRC=DST` IP-mapping spec. Both sides must be IP addresses of the
483/// same family.
484fn parse_ip_pair(spec: &str) -> Result<(IpAddr, IpAddr)> {
485    let (src, dst) = spec
486        .split_once('=')
487        .ok_or_else(|| Error::parse(format!("invalid IP mapping '{spec}': expected SRC=DST")))?;
488    let src = src.trim();
489    let dst = dst.trim();
490    let source: IpAddr = src
491        .parse()
492        .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{src}' is not an IP")))?;
493    let dest: IpAddr = dst
494        .parse()
495        .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{dst}' is not an IP")))?;
496    if source.is_ipv4() != dest.is_ipv4() {
497        return Err(Error::parse(format!(
498            "invalid IP mapping '{spec}': mixes IPv4 and IPv6"
499        )));
500    }
501    Ok((source, dest))
502}
503
504/// A canonical string for a record's data, used to compare record values.
505fn canonical(record: &RecordData) -> String {
506    record
507        .to_api_params()
508        .into_iter()
509        .map(|(key, value)| format!("{key}\u{1}{value}"))
510        .collect::<Vec<_>>()
511        .join("\u{2}")
512}
513
514/// A stable sort key so plan output is deterministic.
515fn sort_key(record: &PlannedRecord) -> (String, String, String) {
516    (
517        record.fqdn.to_lowercase(),
518        record.rtype.clone(),
519        canonical(&record.record),
520    )
521}
522
523/// A compact, human-readable rendering of a record's value.
524fn value_display(record: &RecordData) -> String {
525    record
526        .to_api_params()
527        .into_iter()
528        .skip(1) // drop the leading ("type", ...) param
529        .map(|(_, value)| value)
530        .collect::<Vec<_>>()
531        .join(" ")
532}
533
534/// Print the sync plan as an aligned table.
535fn render_table(from_id: &str, to_id: &str, plans: &[ZonePlan], apply: bool) {
536    let mode = if apply { "apply" } else { "dry run" };
537    println!("Sync plan: {from_id} -> {to_id}  ({mode})");
538
539    let mut adds = 0;
540    let mut deletes = 0;
541    let mut unchanged = 0;
542    let mut skipped = 0;
543    let mut untouched = 0;
544
545    for plan in plans {
546        adds += plan.adds.len();
547        deletes += plan.deletes.len();
548        unchanged += plan.unchanged;
549        skipped += plan.skipped;
550        untouched += plan.untouched;
551
552        if plan.adds.is_empty() && plan.deletes.is_empty() {
553            continue;
554        }
555        println!("\nZone: {}", plan.zone);
556        for rec in &plan.adds {
557            println!(
558                "  + {:<28} {:<6} {}",
559                rec.fqdn,
560                rec.rtype,
561                value_display(&rec.record)
562            );
563        }
564        for rec in &plan.deletes {
565            println!(
566                "  - {:<28} {:<6} {}",
567                rec.fqdn,
568                rec.rtype,
569                value_display(&rec.record)
570            );
571        }
572    }
573
574    println!(
575        "\n{adds} to add, {deletes} to remove, {unchanged} unchanged, \
576         {skipped} skipped (not syncable)."
577    );
578    if untouched > 0 {
579        println!("{untouched} destination record(s) absent from the source were left untouched.");
580    }
581    if adds + deletes == 0 {
582        println!("Already in sync — nothing to do.");
583    } else if !apply {
584        println!("Dry run — no changes written. Re-run with --apply to commit.");
585    }
586}
587
588/// Print the sync plan as JSON.
589fn sync_plan_json(
590    from_id: &str,
591    to_id: &str,
592    plans: &[ZonePlan],
593    apply: bool,
594) -> serde_json::Value {
595    let rec_json = |rec: &PlannedRecord| {
596        serde_json::json!({
597            "name": rec.fqdn,
598            "type": rec.rtype,
599            "ttl": rec.ttl,
600            "value": value_display(&rec.record),
601        })
602    };
603
604    let zones: Vec<_> = plans
605        .iter()
606        .map(|plan| {
607            serde_json::json!({
608                "zone": plan.zone,
609                "add": plan.adds.iter().map(rec_json).collect::<Vec<_>>(),
610                "remove": plan.deletes.iter().map(rec_json).collect::<Vec<_>>(),
611                "unchanged": plan.unchanged,
612                "untouched": plan.untouched,
613                "skipped": plan.skipped,
614            })
615        })
616        .collect();
617
618    serde_json::json!({
619        "from": from_id,
620        "to": to_id,
621        "applied": apply,
622        "zones": zones,
623    })
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use crate::core::dns::responses::{ZoneInfo, ZoneRecord, ZoneRecords};
630    use rstest::rstest;
631    use serde_json::{Value, json};
632    use std::sync::{Arc, Mutex};
633
634    fn ip_map(pairs: &[(&str, &str)]) -> HashMap<IpAddr, IpAddr> {
635        pairs
636            .iter()
637            .map(|(s, d)| (s.parse().unwrap(), d.parse().unwrap()))
638            .collect()
639    }
640
641    fn a(name: &str, ip: &str) -> PlannedRecord {
642        PlannedRecord {
643            fqdn: name.to_string(),
644            rtype: "A".to_string(),
645            ttl: 3600,
646            record: RecordData::A {
647                ip: ip.parse().unwrap(),
648            },
649        }
650    }
651
652    fn zone_info(name: &str) -> ZoneInfo {
653        ZoneInfo {
654            id: Some(name.to_string()),
655            name: name.to_string(),
656            zone_type: "Primary".to_string(),
657            disabled: false,
658            dnssec_status: None,
659        }
660    }
661
662    fn zone_record(name: &str, record_type: &str, ttl: u32, data: Value) -> ZoneRecord {
663        let mut record = ZoneRecord {
664            name: name.to_string(),
665            record_type: record_type.to_string(),
666            ttl,
667            disabled: false,
668            comments: String::new(),
669            expiry_ttl: 0,
670            data,
671            parsed: None,
672        };
673        record.parsed = record.typed();
674        record
675    }
676
677    fn sync_test_response(zone: &str, records: Vec<ZoneRecord>) -> ListRecordsResponse {
678        ListRecordsResponse {
679            zones: vec![ZoneRecords {
680                zone: zone_info(zone),
681                records,
682            }],
683        }
684    }
685
686    #[derive(Clone)]
687    struct FakeZoneRead {
688        response: ListRecordsResponse,
689        calls: Arc<Mutex<Vec<(String, Option<String>, ListRecordsOptions)>>>,
690    }
691
692    impl FakeZoneRead {
693        fn new(response: ListRecordsResponse) -> Self {
694            Self {
695                response,
696                calls: Arc::new(Mutex::new(Vec::new())),
697            }
698        }
699    }
700
701    impl ZoneRead for FakeZoneRead {
702        async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
703            Ok(json!({ "response": { "zones": [] } }))
704        }
705
706        async fn list_records(
707            &self,
708            domain: &str,
709            zone: Option<&str>,
710            options: ListRecordsOptions,
711        ) -> Result<ListRecordsResponse> {
712            self.calls.lock().unwrap().push((
713                domain.to_string(),
714                zone.map(ToOwned::to_owned),
715                options,
716            ));
717            Ok(self.response.clone())
718        }
719    }
720
721    #[derive(Default)]
722    struct FakeRecordWrite {
723        adds: Mutex<Vec<(String, String, u32, RecordData)>>,
724        deletes: Mutex<Vec<(String, String, Vec<(String, String)>)>>,
725    }
726
727    impl RecordWrite for FakeRecordWrite {
728        async fn add_record(
729            &self,
730            zone: &str,
731            domain: &str,
732            ttl: u32,
733            record: &RecordData,
734        ) -> Result<Value> {
735            self.adds.lock().unwrap().push((
736                zone.to_string(),
737                domain.to_string(),
738                ttl,
739                record.clone(),
740            ));
741            Ok(json!({ "status": "ok" }))
742        }
743
744        async fn delete_record(
745            &self,
746            zone: &str,
747            domain: &str,
748            type_params: &[(&str, String)],
749        ) -> Result<Value> {
750            self.deletes.lock().unwrap().push((
751                zone.to_string(),
752                domain.to_string(),
753                type_params
754                    .iter()
755                    .map(|(key, value)| ((*key).to_string(), value.clone()))
756                    .collect(),
757            ));
758            Ok(json!({ "status": "ok" }))
759        }
760    }
761
762    // ── apply_ip_map ──────────────────────────────────────────────────────────
763
764    #[test]
765    fn ip_map_rewrites_mapped_a_record() {
766        let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
767        let mapped = apply_ip_map(
768            RecordData::A {
769                ip: "203.0.113.10".parse().unwrap(),
770            },
771            &map,
772        );
773        match mapped {
774            RecordData::A { ip } => assert_eq!(ip.to_string(), "192.168.1.10"),
775            other => panic!("expected A, got {other:?}"),
776        }
777    }
778
779    #[test]
780    fn ip_map_leaves_unmapped_a_record_untouched() {
781        let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
782        let mapped = apply_ip_map(
783            RecordData::A {
784                ip: "8.8.8.8".parse().unwrap(),
785            },
786            &map,
787        );
788        match mapped {
789            RecordData::A { ip } => assert_eq!(ip.to_string(), "8.8.8.8"),
790            other => panic!("expected A, got {other:?}"),
791        }
792    }
793
794    #[test]
795    fn ip_map_rewrites_mapped_aaaa_record() {
796        let map = ip_map(&[("2001:db8::1", "fd00::1")]);
797        let mapped = apply_ip_map(
798            RecordData::Aaaa {
799                ip: "2001:db8::1".parse().unwrap(),
800            },
801            &map,
802        );
803        match mapped {
804            RecordData::Aaaa { ip } => assert_eq!(ip.to_string(), "fd00::1"),
805            other => panic!("expected AAAA, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn ip_map_leaves_non_address_records_untouched() {
811        let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
812        let mapped = apply_ip_map(
813            RecordData::Cname {
814                target: "example.com".to_string(),
815            },
816            &map,
817        );
818        assert!(matches!(mapped, RecordData::Cname { .. }));
819    }
820
821    // ── plan/apply ────────────────────────────────────────────────────────────
822
823    #[tokio::test]
824    async fn plan_zone_lists_entire_zone_and_includes_child_records() {
825        let zone = "dnsync-sync-test.example";
826        let source = FakeZoneRead::new(sync_test_response(
827            zone,
828            vec![
829                zone_record(zone, "SOA", 3600, json!({})),
830                zone_record(zone, "NS", 3600, json!({ "nameServer": "dns1.hankin.io" })),
831                zone_record(
832                    &format!("www.{zone}"),
833                    "A",
834                    3600,
835                    json!({ "ipAddress": "203.0.113.10" }),
836                ),
837                zone_record(
838                    &format!("api.{zone}"),
839                    "CNAME",
840                    3600,
841                    json!({ "cname": format!("www.{zone}") }),
842                ),
843            ],
844        ));
845        let dest = FakeZoneRead::new(sync_test_response(
846            zone,
847            vec![
848                zone_record(zone, "SOA", 3600, json!({})),
849                zone_record(zone, "NS", 3600, json!({ "nameServer": "dns2.hankin.io" })),
850            ],
851        ));
852
853        let plan = plan_zone_with_clients(&source, &dest, zone, &HashMap::new())
854            .await
855            .unwrap();
856
857        assert!(source.calls.lock().unwrap()[0].2.all_subdomains);
858        assert!(dest.calls.lock().unwrap()[0].2.all_subdomains);
859        assert_eq!(plan.adds.len(), 2);
860        assert!(plan.adds.iter().any(|r| {
861            r.fqdn == format!("www.{zone}")
862                && r.rtype == "A"
863                && value_display(&r.record) == "203.0.113.10"
864        }));
865        assert!(plan.adds.iter().any(|r| {
866            r.fqdn == format!("api.{zone}")
867                && r.rtype == "CNAME"
868                && value_display(&r.record) == format!("www.{zone}")
869        }));
870        assert_eq!(plan.skipped, 2);
871    }
872
873    #[tokio::test]
874    async fn apply_writes_missing_child_records_to_destination() {
875        let zone = "dnsync-sync-test.example";
876        let writer = FakeRecordWrite::default();
877        let plan = ZonePlan {
878            zone: zone.to_string(),
879            adds: vec![
880                PlannedRecord {
881                    fqdn: format!("www.{zone}"),
882                    rtype: "A".to_string(),
883                    ttl: 3600,
884                    record: RecordData::A {
885                        ip: "203.0.113.10".parse().unwrap(),
886                    },
887                },
888                PlannedRecord {
889                    fqdn: format!("api.{zone}"),
890                    rtype: "CNAME".to_string(),
891                    ttl: 3600,
892                    record: RecordData::Cname {
893                        target: format!("www.{zone}"),
894                    },
895                },
896            ],
897            deletes: vec![],
898            unchanged: 0,
899            untouched: 0,
900            skipped: 0,
901        };
902
903        apply_plans_with_client(&writer, &[plan]).await.unwrap();
904
905        let adds = writer.adds.lock().unwrap();
906        assert_eq!(adds.len(), 2);
907        assert_eq!(adds[0].0, zone);
908        assert_eq!(adds[0].1, format!("www.{zone}"));
909        assert!(matches!(adds[0].3, RecordData::A { .. }));
910        assert_eq!(adds[1].1, format!("api.{zone}"));
911        assert!(matches!(adds[1].3, RecordData::Cname { .. }));
912    }
913
914    #[tokio::test]
915    async fn plan_zone_applies_ip_mapping_to_child_address_records() {
916        let zone = "dnsync-sync-test.example";
917        let source = FakeZoneRead::new(sync_test_response(
918            zone,
919            vec![zone_record(
920                &format!("www.{zone}"),
921                "A",
922                3600,
923                json!({ "ipAddress": "203.0.113.10" }),
924            )],
925        ));
926        let dest = FakeZoneRead::new(sync_test_response(zone, vec![]));
927        let map = ip_map(&[("203.0.113.10", "192.0.2.10")]);
928
929        let plan = plan_zone_with_clients(&source, &dest, zone, &map)
930            .await
931            .unwrap();
932
933        assert_eq!(plan.adds.len(), 1);
934        assert_eq!(value_display(&plan.adds[0].record), "192.0.2.10");
935    }
936
937    // ── parse_ip_pair ─────────────────────────────────────────────────────────
938
939    #[test]
940    fn parse_ip_pair_accepts_valid_pair() {
941        let (s, d) = parse_ip_pair("203.0.113.10 = 192.168.1.10").unwrap();
942        assert_eq!(s.to_string(), "203.0.113.10");
943        assert_eq!(d.to_string(), "192.168.1.10");
944    }
945
946    #[rstest]
947    #[case::missing_separator("203.0.113.10")]
948    #[case::bad_address("203.0.113.10=not-an-ip")]
949    #[case::family_mismatch("203.0.113.10=fd00::1")]
950    fn parse_ip_pair_rejects_bad_input(#[case] spec: &str) {
951        assert!(parse_ip_pair(spec).is_err());
952    }
953
954    // ── canonical ─────────────────────────────────────────────────────────────
955
956    #[test]
957    fn canonical_equal_for_same_value_differs_for_others() {
958        let one = RecordData::A {
959            ip: "1.2.3.4".parse().unwrap(),
960        };
961        let same = RecordData::A {
962            ip: "1.2.3.4".parse().unwrap(),
963        };
964        let other = RecordData::A {
965            ip: "1.2.3.5".parse().unwrap(),
966        };
967        assert_eq!(canonical(&one), canonical(&same));
968        assert_ne!(canonical(&one), canonical(&other));
969    }
970
971    // ── diff_records ──────────────────────────────────────────────────────────
972
973    #[test]
974    fn diff_adds_record_set_missing_on_destination() {
975        let diff = diff_records(vec![a("www.example.com", "1.1.1.1")], vec![]);
976        assert_eq!(diff.adds.len(), 1);
977        assert_eq!(diff.deletes.len(), 0);
978        assert_eq!(diff.unchanged, 0);
979    }
980
981    #[test]
982    fn diff_updates_changed_value_with_add_and_remove() {
983        let diff = diff_records(
984            vec![a("www.example.com", "2.2.2.2")],
985            vec![a("www.example.com", "1.1.1.1")],
986        );
987        assert_eq!(diff.adds.len(), 1);
988        assert_eq!(diff.deletes.len(), 1);
989        assert_eq!(diff.unchanged, 0);
990        match &diff.adds[0].record {
991            RecordData::A { ip } => assert_eq!(ip.to_string(), "2.2.2.2"),
992            other => panic!("expected A, got {other:?}"),
993        }
994    }
995
996    #[test]
997    fn diff_reports_identical_records_as_unchanged() {
998        let diff = diff_records(
999            vec![a("www.example.com", "1.1.1.1")],
1000            vec![a("www.example.com", "1.1.1.1")],
1001        );
1002        assert_eq!(diff.adds.len(), 0);
1003        assert_eq!(diff.deletes.len(), 0);
1004        assert_eq!(diff.unchanged, 1);
1005    }
1006
1007    #[test]
1008    fn diff_treats_ttl_difference_as_update() {
1009        let mut src = a("www.example.com", "1.1.1.1");
1010        src.ttl = 300;
1011        let mut dst = a("www.example.com", "1.1.1.1");
1012        dst.ttl = 3600;
1013        let diff = diff_records(vec![src], vec![dst]);
1014        assert_eq!(diff.adds.len(), 1);
1015        assert_eq!(diff.deletes.len(), 1);
1016        assert_eq!(diff.unchanged, 0);
1017        assert_eq!(diff.adds[0].ttl, 300);
1018    }
1019
1020    #[test]
1021    fn diff_never_prunes_destination_only_names() {
1022        let diff = diff_records(
1023            vec![a("a.example.com", "1.1.1.1")],
1024            vec![a("a.example.com", "1.1.1.1"), a("b.example.com", "2.2.2.2")],
1025        );
1026        assert_eq!(diff.adds.len(), 0);
1027        assert_eq!(diff.deletes.len(), 0);
1028        assert_eq!(diff.unchanged, 1);
1029        assert_eq!(diff.untouched, 1);
1030    }
1031}