Skip to main content

tatara_lisp_script/stdlib/
dns.rs

1//! DNS provider domain — the `pleme/dns` surface.
2//!
3//! Provides the slash-named functions every `lareira-dns-reconciler`
4//! computeunit calls:
5//!
6//!   (dns/upsert :provider :route53 :zone-id Z :credentials C
7//!               :record-type :CNAME :name "x.quero.lol" :value "lb…"
8//!               :ttl 60 :proxied false)        → {:status "ok" …}
9//!   (dns/delete :provider … :zone-id … :credentials … :record-type …
10//!               :name … :value … :ttl …)        → {:status "ok" …}
11//!   (dns/list   :provider … :zone-id … :credentials …)
12//!                                                → ((…) (…) …)
13//!
14//! Two providers are wired end to end:
15//!
16//!   * `:cloudflare` — REST over `api.cloudflare.com/client/v4`, Bearer
17//!     token auth (creds key `api-token` / `token`). JSON wire format.
18//!   * `:route53` — `ChangeResourceRecordSets` / `ListResourceRecordSets`
19//!     over `route53.amazonaws.com`, **AWS SigV4** request signing (creds
20//!     keys `access-key-id` + `secret-access-key`, optional
21//!     `session-token` + `region`). XML wire format, built through a
22//!     typed [`ChangeBatch`] `Display` impl — never `format!()`-of-XML.
23//!
24//! `:hetzner` / `:gcp` are declared in the reconciler's provider enum but
25//! return a typed `unimplemented provider` error here — never a silent
26//! wrong answer (org CLAUDE.md TYPED-SPEC rule).
27//!
28//! ## Testability
29//!
30//! All network egress goes through the [`DnsTransport`] trait — the
31//! Environment seam from the org CLAUDE.md typed-spec-triplet rule. The
32//! production impl ([`UreqTransport`]) wraps the shared `ureq::Agent`;
33//! tests drive a [`MockTransport`] that records the request and returns a
34//! canned response, so every provider path + the SigV4 signer is unit
35//! tested with zero network. The signer is validated against AWS's
36//! published GET-vanilla example vector.
37
38use std::collections::HashMap;
39use std::sync::Arc;
40use std::time::{SystemTime, UNIX_EPOCH};
41
42use hmac::{Hmac, Mac};
43use sha2::{Digest, Sha256};
44use tatara_lisp_eval::value::MapKey;
45use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
46
47use crate::script_ctx::ScriptCtx;
48
49type HmacSha256 = Hmac<Sha256>;
50
51const FN_UPSERT: &str = "dns/upsert";
52const FN_DELETE: &str = "dns/delete";
53const FN_LIST: &str = "dns/list";
54
55// ─────────────────────────────────────────────────────────────────────
56// Registration
57// ─────────────────────────────────────────────────────────────────────
58
59pub fn install(interp: &mut Interpreter<ScriptCtx>) {
60    interp.register_fn(
61        FN_UPSERT,
62        Arity::AtLeast(2),
63        |args: &[Value], ctx: &mut ScriptCtx, sp| run(Action::Upsert, args, ctx, sp),
64    );
65    interp.register_fn(
66        FN_DELETE,
67        Arity::AtLeast(2),
68        |args: &[Value], ctx: &mut ScriptCtx, sp| run(Action::Delete, args, ctx, sp),
69    );
70    interp.register_fn(
71        FN_LIST,
72        Arity::AtLeast(2),
73        |args: &[Value], ctx: &mut ScriptCtx, sp| {
74            let kw = Kwargs::parse(args, FN_LIST, sp)?;
75            let provider = kw.provider(FN_LIST, sp)?;
76            let zone_id = kw.want_str("zone-id", FN_LIST, sp)?;
77            let creds = kw.credentials(FN_LIST, sp)?;
78            let transport = UreqTransport::new(ctx);
79            let records = dns_list(&transport, provider, &zone_id, &creds)
80                .map_err(|e| EvalError::native_fn(FN_LIST, e, sp))?;
81            Ok(Value::List(Arc::new(
82                records.into_iter().map(Record::into_value).collect(),
83            )))
84        },
85    );
86}
87
88/// Shared body for `dns/upsert` + `dns/delete` — they differ only in the
89/// Route53 `Action` / Cloudflare verb.
90fn run(
91    action: Action,
92    args: &[Value],
93    ctx: &mut ScriptCtx,
94    sp: tatara_lisp::Span,
95) -> Result<Value, EvalError> {
96    let fn_name = action.fn_name();
97    let kw = Kwargs::parse(args, fn_name, sp)?;
98    let req = ChangeRequest {
99        provider: kw.provider(fn_name, sp)?,
100        zone_id: kw.want_str("zone-id", fn_name, sp)?,
101        credentials: kw.credentials(fn_name, sp)?,
102        record_type: kw.record_type(fn_name, sp)?,
103        name: kw.want_str("name", fn_name, sp)?,
104        value: kw.want_str("value", fn_name, sp)?,
105        ttl: kw.opt_int("ttl").unwrap_or(300),
106        proxied: kw.opt_bool("proxied").unwrap_or(false),
107        action,
108    };
109    let transport = UreqTransport::new(ctx);
110    let outcome = dns_change(&transport, &req).map_err(|e| EvalError::native_fn(fn_name, e, sp))?;
111    Ok(outcome.into_value())
112}
113
114// ─────────────────────────────────────────────────────────────────────
115// Typed request / response model
116// ─────────────────────────────────────────────────────────────────────
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Provider {
120    Cloudflare,
121    Route53,
122    Hetzner,
123    Gcp,
124}
125
126impl Provider {
127    fn from_keyword(s: &str) -> Option<Self> {
128        match s.to_ascii_lowercase().as_str() {
129            "cloudflare" => Some(Self::Cloudflare),
130            "route53" => Some(Self::Route53),
131            "hetzner" => Some(Self::Hetzner),
132            "gcp" => Some(Self::Gcp),
133            _ => None,
134        }
135    }
136    fn as_str(self) -> &'static str {
137        match self {
138            Self::Cloudflare => "cloudflare",
139            Self::Route53 => "route53",
140            Self::Hetzner => "hetzner",
141            Self::Gcp => "gcp",
142        }
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum RecordType {
148    A,
149    Aaaa,
150    Cname,
151    Txt,
152}
153
154impl RecordType {
155    /// Liberal parse — the two DnsRule schemas in the fleet spell the
156    /// keyword differently (`:A`/`:a`, `:AAAA`/`:a-a-a-a`,
157    /// `:CNAME`/`:c-n-a-m-e`, `:TXT`/`:t-x-t`). Accept all of them.
158    fn from_keyword(s: &str) -> Option<Self> {
159        let norm: String = s
160            .chars()
161            .filter(|c| *c != '-')
162            .collect::<String>()
163            .to_ascii_lowercase();
164        match norm.as_str() {
165            "a" => Some(Self::A),
166            "aaaa" => Some(Self::Aaaa),
167            "cname" => Some(Self::Cname),
168            "txt" => Some(Self::Txt),
169            _ => None,
170        }
171    }
172    fn as_str(self) -> &'static str {
173        match self {
174            Self::A => "A",
175            Self::Aaaa => "AAAA",
176            Self::Cname => "CNAME",
177            Self::Txt => "TXT",
178        }
179    }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum Action {
184    Upsert,
185    Delete,
186}
187
188impl Action {
189    fn fn_name(self) -> &'static str {
190        match self {
191            Self::Upsert => FN_UPSERT,
192            Self::Delete => FN_DELETE,
193        }
194    }
195    /// Route53 ChangeBatch action verb.
196    fn route53_verb(self) -> &'static str {
197        match self {
198            Self::Upsert => "UPSERT",
199            Self::Delete => "DELETE",
200        }
201    }
202}
203
204/// Provider-agnostic credentials, parsed from the `:credentials` map a
205/// computeunit gets from `(k8s/read-secret …)`.
206#[derive(Debug, Clone, Default)]
207pub struct Credentials {
208    pub map: HashMap<String, String>,
209}
210
211impl Credentials {
212    fn get(&self, keys: &[&str]) -> Option<&str> {
213        keys.iter()
214            .find_map(|k| self.map.get(*k).map(String::as_str))
215    }
216    fn cloudflare_token(&self) -> Option<&str> {
217        self.get(&["api-token", "api_token", "token", "CLOUDFLARE_API_TOKEN"])
218    }
219    fn aws_access_key(&self) -> Option<&str> {
220        self.get(&[
221            "access-key-id",
222            "access_key_id",
223            "aws-access-key-id",
224            "AWS_ACCESS_KEY_ID",
225        ])
226    }
227    fn aws_secret_key(&self) -> Option<&str> {
228        self.get(&[
229            "secret-access-key",
230            "secret_access_key",
231            "aws-secret-access-key",
232            "AWS_SECRET_ACCESS_KEY",
233        ])
234    }
235    fn aws_session_token(&self) -> Option<&str> {
236        self.get(&[
237            "session-token",
238            "session_token",
239            "aws-session-token",
240            "AWS_SESSION_TOKEN",
241        ])
242    }
243    fn aws_region(&self) -> &str {
244        // Route53 is a global service; SigV4 still requires a region and
245        // AWS canonicalizes Route53 to us-east-1.
246        self.get(&["region", "aws-region", "AWS_REGION"])
247            .unwrap_or("us-east-1")
248    }
249}
250
251pub struct ChangeRequest {
252    pub provider: Provider,
253    pub zone_id: String,
254    pub credentials: Credentials,
255    pub record_type: RecordType,
256    pub name: String,
257    pub value: String,
258    pub ttl: i64,
259    pub proxied: bool,
260    pub action: Action,
261}
262
263/// Result returned to Lisp as a map.
264#[derive(Debug)]
265pub struct Outcome {
266    provider: Provider,
267    action: Action,
268    name: String,
269    record_type: RecordType,
270}
271
272impl Outcome {
273    fn into_value(self) -> Value {
274        map_value(&[
275            ("status", Value::Str(Arc::from("ok"))),
276            ("provider", Value::Str(Arc::from(self.provider.as_str()))),
277            (
278                "action",
279                Value::Str(Arc::from(self.action.route53_verb().to_ascii_lowercase())),
280            ),
281            ("name", Value::Str(Arc::from(self.name))),
282            ("type", Value::Str(Arc::from(self.record_type.as_str()))),
283        ])
284    }
285}
286
287/// A single record returned by `dns/list`.
288#[derive(Debug)]
289pub struct Record {
290    name: String,
291    record_type: String,
292    value: String,
293    ttl: Option<i64>,
294}
295
296impl Record {
297    fn into_value(self) -> Value {
298        let mut fields = vec![
299            ("name", Value::Str(Arc::from(self.name))),
300            ("type", Value::Str(Arc::from(self.record_type))),
301            ("value", Value::Str(Arc::from(self.value))),
302        ];
303        if let Some(ttl) = self.ttl {
304            fields.push(("ttl", Value::Int(ttl)));
305        }
306        map_value(&fields)
307    }
308}
309
310// ─────────────────────────────────────────────────────────────────────
311// Transport seam (the Environment trait)
312// ─────────────────────────────────────────────────────────────────────
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Method {
316    Get,
317    Post,
318    Put,
319    Delete,
320}
321
322pub struct HttpRequest {
323    pub method: Method,
324    pub url: String,
325    pub headers: Vec<(String, String)>,
326    pub body: Option<String>,
327}
328
329pub struct HttpResponse {
330    pub status: u16,
331    pub body: String,
332}
333
334impl HttpResponse {
335    fn is_success(&self) -> bool {
336        (200..300).contains(&self.status)
337    }
338}
339
340/// The single egress point every provider funnels through. Mockable in
341/// tests; the production impl is [`UreqTransport`].
342pub trait DnsTransport {
343    fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String>;
344}
345
346/// Production transport — borrows a clone of the script's shared agent.
347pub struct UreqTransport {
348    agent: ureq::Agent,
349}
350
351impl UreqTransport {
352    fn new(ctx: &mut ScriptCtx) -> Self {
353        Self {
354            agent: ctx.http().clone(),
355        }
356    }
357}
358
359impl DnsTransport for UreqTransport {
360    fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String> {
361        let ctx = || format!("{} {}", method_str(req.method), req.url);
362        match req.method {
363            Method::Get | Method::Delete => {
364                let mut b = if req.method == Method::Get {
365                    self.agent.get(&req.url)
366                } else {
367                    self.agent.delete(&req.url)
368                };
369                for (k, v) in &req.headers {
370                    b = b.header(k.as_str(), v.as_str());
371                }
372                let resp = b.call().map_err(|e| format!("{}: {e}", ctx()))?;
373                let status = resp.status().as_u16();
374                let body = resp
375                    .into_body()
376                    .read_to_string()
377                    .map_err(|e| format!("{}: {e}", ctx()))?;
378                Ok(HttpResponse { status, body })
379            }
380            Method::Post | Method::Put => {
381                let mut b = if req.method == Method::Post {
382                    self.agent.post(&req.url)
383                } else {
384                    self.agent.put(&req.url)
385                };
386                for (k, v) in &req.headers {
387                    b = b.header(k.as_str(), v.as_str());
388                }
389                let body = req.body.clone().unwrap_or_default();
390                let resp = b.send(body).map_err(|e| format!("{}: {e}", ctx()))?;
391                let status = resp.status().as_u16();
392                let body = resp
393                    .into_body()
394                    .read_to_string()
395                    .map_err(|e| format!("{}: {e}", ctx()))?;
396                Ok(HttpResponse { status, body })
397            }
398        }
399    }
400}
401
402fn method_str(m: Method) -> &'static str {
403    match m {
404        Method::Get => "GET",
405        Method::Post => "POST",
406        Method::Put => "PUT",
407        Method::Delete => "DELETE",
408    }
409}
410
411// ─────────────────────────────────────────────────────────────────────
412// Provider dispatch
413// ─────────────────────────────────────────────────────────────────────
414
415fn dns_change(t: &dyn DnsTransport, req: &ChangeRequest) -> Result<Outcome, String> {
416    match req.provider {
417        Provider::Route53 => route53::change(t, req, now_unix()),
418        Provider::Cloudflare => cloudflare::change(t, req),
419        other => Err(unimplemented(other)),
420    }
421}
422
423fn dns_list(
424    t: &dyn DnsTransport,
425    provider: Provider,
426    zone_id: &str,
427    creds: &Credentials,
428) -> Result<Vec<Record>, String> {
429    match provider {
430        Provider::Route53 => route53::list(t, zone_id, creds, now_unix()),
431        Provider::Cloudflare => cloudflare::list(t, zone_id, creds),
432        other => Err(unimplemented(other)),
433    }
434}
435
436fn unimplemented(p: Provider) -> String {
437    format!(
438        "DNS provider '{}' is declared but not implemented — wire it in tatara-lisp-script/src/stdlib/dns.rs (no silent fallback)",
439        p.as_str()
440    )
441}
442
443fn now_unix() -> u64 {
444    SystemTime::now()
445        .duration_since(UNIX_EPOCH)
446        .map(|d| d.as_secs())
447        .unwrap_or(0)
448}
449
450// ─────────────────────────────────────────────────────────────────────
451// Route53 provider
452// ─────────────────────────────────────────────────────────────────────
453
454mod route53 {
455    use super::*;
456
457    const API_VERSION: &str = "2013-04-01";
458    const HOST: &str = "route53.amazonaws.com";
459    const SERVICE: &str = "route53";
460
461    pub(super) fn change(
462        t: &dyn DnsTransport,
463        req: &ChangeRequest,
464        now: u64,
465    ) -> Result<Outcome, String> {
466        let zone = bare_zone_id(&req.zone_id);
467        let body = ChangeBatch {
468            action: req.action.route53_verb(),
469            name: &req.name,
470            rtype: req.record_type.as_str(),
471            ttl: req.ttl.max(0),
472            value: &formatted_value(req.record_type, &req.value),
473        }
474        .to_string();
475
476        let path = format!("/{API_VERSION}/hostedzone/{zone}/rrset/");
477        let url = format!("https://{HOST}{path}");
478        let canonical_uri = uri_encode_path(&path);
479        let signer = Sigv4 {
480            method: "POST",
481            host: HOST,
482            canonical_uri: &canonical_uri,
483            canonical_query: "",
484            payload: body.as_bytes(),
485            region: req.credentials.aws_region(),
486            service: SERVICE,
487            access_key: cred_required(req.credentials.aws_access_key(), "access-key-id")?,
488            secret_key: cred_required(req.credentials.aws_secret_key(), "secret-access-key")?,
489            session_token: req.credentials.aws_session_token(),
490            amz: AmzDate::from_unix(now),
491        };
492        let mut headers = signer.signed_headers();
493        headers.push(("content-type".into(), "application/xml".into()));
494
495        let resp = t.send(&HttpRequest {
496            method: Method::Post,
497            url,
498            headers,
499            body: Some(body),
500        })?;
501        if !resp.is_success() {
502            return Err(format!(
503                "route53 ChangeResourceRecordSets failed ({}): {}",
504                resp.status,
505                resp.body.trim()
506            ));
507        }
508        Ok(Outcome {
509            provider: Provider::Route53,
510            action: req.action,
511            name: req.name.clone(),
512            record_type: req.record_type,
513        })
514    }
515
516    pub(super) fn list(
517        t: &dyn DnsTransport,
518        zone_id: &str,
519        creds: &Credentials,
520        now: u64,
521    ) -> Result<Vec<Record>, String> {
522        let zone = bare_zone_id(zone_id);
523        let path = format!("/{API_VERSION}/hostedzone/{zone}/rrset");
524        let url = format!("https://{HOST}{path}");
525        let canonical_uri = uri_encode_path(&path);
526        let signer = Sigv4 {
527            method: "GET",
528            host: HOST,
529            canonical_uri: &canonical_uri,
530            canonical_query: "",
531            payload: b"",
532            region: creds.aws_region(),
533            service: SERVICE,
534            access_key: cred_required(creds.aws_access_key(), "access-key-id")?,
535            secret_key: cred_required(creds.aws_secret_key(), "secret-access-key")?,
536            session_token: creds.aws_session_token(),
537            amz: AmzDate::from_unix(now),
538        };
539        let resp = t.send(&HttpRequest {
540            method: Method::Get,
541            url,
542            headers: signer.signed_headers(),
543            body: None,
544        })?;
545        if !resp.is_success() {
546            return Err(format!(
547                "route53 ListResourceRecordSets failed ({}): {}",
548                resp.status,
549                resp.body.trim()
550            ));
551        }
552        Ok(parse_rrsets(&resp.body))
553    }
554
555    /// Strip a leading `/hostedzone/` prefix operators sometimes paste in.
556    fn bare_zone_id(z: &str) -> &str {
557        z.trim_start_matches("/hostedzone/").trim_start_matches('/')
558    }
559
560    /// Route53 wants TXT values double-quoted on the wire.
561    pub(super) fn formatted_value(rtype: RecordType, value: &str) -> String {
562        if rtype == RecordType::Txt && !(value.starts_with('"') && value.ends_with('"')) {
563            format!("\"{}\"", value.replace('"', "\\\""))
564        } else {
565            value.to_string()
566        }
567    }
568
569    /// Minimal, dependency-free scan of the ListResourceRecordSets XML —
570    /// enough for drift detection. Pulls Name/Type/TTL + the first Value
571    /// from each `<ResourceRecordSet>`.
572    fn parse_rrsets(xml: &str) -> Vec<Record> {
573        let mut out = Vec::new();
574        for block in split_between(xml, "<ResourceRecordSet>", "</ResourceRecordSet>") {
575            let name = tag(block, "Name").unwrap_or_default();
576            let rtype = tag(block, "Type").unwrap_or_default();
577            if name.is_empty() || rtype.is_empty() {
578                continue;
579            }
580            let ttl = tag(block, "TTL").and_then(|t| t.trim().parse::<i64>().ok());
581            let value = tag(block, "Value").unwrap_or_default();
582            out.push(Record {
583                name: xml_unescape(&name),
584                record_type: rtype,
585                value: xml_unescape(&value),
586                ttl,
587            });
588        }
589        out
590    }
591
592    fn split_between<'a>(s: &'a str, open: &str, close: &str) -> Vec<&'a str> {
593        let mut blocks = Vec::new();
594        let mut rest = s;
595        while let Some(start) = rest.find(open) {
596            let after = &rest[start + open.len()..];
597            if let Some(end) = after.find(close) {
598                blocks.push(&after[..end]);
599                rest = &after[end + close.len()..];
600            } else {
601                break;
602            }
603        }
604        blocks
605    }
606
607    fn tag(block: &str, name: &str) -> Option<String> {
608        let open = format!("<{name}>");
609        let close = format!("</{name}>");
610        let start = block.find(&open)? + open.len();
611        let end = block[start..].find(&close)? + start;
612        Some(block[start..end].to_string())
613    }
614}
615
616/// Typed Route53 `ChangeResourceRecordSets` request body. The `Display`
617/// impl is the typed-emission surface — XML is never `format!()`-assembled
618/// ad hoc; values are escaped here, in one place.
619struct ChangeBatch<'a> {
620    action: &'a str,
621    name: &'a str,
622    rtype: &'a str,
623    ttl: i64,
624    value: &'a str,
625}
626
627impl std::fmt::Display for ChangeBatch<'_> {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        write!(f, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
630        write!(f, "<ChangeResourceRecordSetsRequest xmlns=\"https://route53.amazonaws.com/doc/2013-04-01/\">")?;
631        write!(f, "<ChangeBatch><Changes><Change>")?;
632        write!(f, "<Action>{}</Action>", self.action)?;
633        write!(f, "<ResourceRecordSet>")?;
634        write!(f, "<Name>{}</Name>", XmlText(self.name))?;
635        write!(f, "<Type>{}</Type>", self.rtype)?;
636        write!(f, "<TTL>{}</TTL>", self.ttl)?;
637        write!(
638            f,
639            "<ResourceRecords><ResourceRecord><Value>{}</Value></ResourceRecord></ResourceRecords>",
640            XmlText(self.value)
641        )?;
642        write!(f, "</ResourceRecordSet>")?;
643        write!(f, "</Change></Changes></ChangeBatch>")?;
644        write!(f, "</ChangeResourceRecordSetsRequest>")
645    }
646}
647
648/// XML-text-escaping newtype — `Display` emits the escaped form.
649struct XmlText<'a>(&'a str);
650
651impl std::fmt::Display for XmlText<'_> {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        for c in self.0.chars() {
654            match c {
655                '&' => f.write_str("&amp;")?,
656                '<' => f.write_str("&lt;")?,
657                '>' => f.write_str("&gt;")?,
658                '"' => f.write_str("&quot;")?,
659                '\'' => f.write_str("&apos;")?,
660                other => f.write_str(other.encode_utf8(&mut [0; 4]))?,
661            }
662        }
663        Ok(())
664    }
665}
666
667fn xml_unescape(s: &str) -> String {
668    s.replace("&lt;", "<")
669        .replace("&gt;", ">")
670        .replace("&quot;", "\"")
671        .replace("&apos;", "'")
672        .replace("&amp;", "&")
673}
674
675// ─────────────────────────────────────────────────────────────────────
676// Cloudflare provider
677// ─────────────────────────────────────────────────────────────────────
678
679mod cloudflare {
680    use super::*;
681
682    const API: &str = "https://api.cloudflare.com/client/v4";
683
684    pub(super) fn change(t: &dyn DnsTransport, req: &ChangeRequest) -> Result<Outcome, String> {
685        let token = cred_required(req.credentials.cloudflare_token(), "api-token")?;
686        let existing = find_record(t, token, &req.zone_id, req.record_type, &req.name)?;
687        match req.action {
688            Action::Upsert => {
689                let payload = serde_json::json!({
690                    "type": req.record_type.as_str(),
691                    "name": req.name,
692                    "content": req.value,
693                    "ttl": req.ttl.max(1),
694                    "proxied": req.proxied,
695                })
696                .to_string();
697                let (method, url) = match &existing {
698                    Some(id) => (
699                        Method::Put,
700                        format!("{API}/zones/{}/dns_records/{id}", req.zone_id),
701                    ),
702                    None => (
703                        Method::Post,
704                        format!("{API}/zones/{}/dns_records", req.zone_id),
705                    ),
706                };
707                let resp = t.send(&HttpRequest {
708                    method,
709                    url,
710                    headers: auth(token),
711                    body: Some(payload),
712                })?;
713                check_cf(&resp, "upsert dns_record")?;
714            }
715            Action::Delete => {
716                let Some(id) = existing else {
717                    // Already absent — deletes are idempotent.
718                    return Ok(outcome(req));
719                };
720                let resp = t.send(&HttpRequest {
721                    method: Method::Delete,
722                    url: format!("{API}/zones/{}/dns_records/{id}", req.zone_id),
723                    headers: auth(token),
724                    body: None,
725                })?;
726                check_cf(&resp, "delete dns_record")?;
727            }
728        }
729        Ok(outcome(req))
730    }
731
732    pub(super) fn list(
733        t: &dyn DnsTransport,
734        zone_id: &str,
735        creds: &Credentials,
736    ) -> Result<Vec<Record>, String> {
737        let token = cred_required(creds.cloudflare_token(), "api-token")?;
738        let resp = t.send(&HttpRequest {
739            method: Method::Get,
740            url: format!("{API}/zones/{zone_id}/dns_records?per_page=100"),
741            headers: auth(token),
742            body: None,
743        })?;
744        let json = check_cf(&resp, "list dns_records")?;
745        let mut out = Vec::new();
746        if let Some(arr) = json.get("result").and_then(|v| v.as_array()) {
747            for r in arr {
748                out.push(Record {
749                    name: r
750                        .get("name")
751                        .and_then(|v| v.as_str())
752                        .unwrap_or_default()
753                        .to_string(),
754                    record_type: r
755                        .get("type")
756                        .and_then(|v| v.as_str())
757                        .unwrap_or_default()
758                        .to_string(),
759                    value: r
760                        .get("content")
761                        .and_then(|v| v.as_str())
762                        .unwrap_or_default()
763                        .to_string(),
764                    ttl: r.get("ttl").and_then(serde_json::Value::as_i64),
765                });
766            }
767        }
768        Ok(out)
769    }
770
771    fn find_record(
772        t: &dyn DnsTransport,
773        token: &str,
774        zone_id: &str,
775        rtype: RecordType,
776        name: &str,
777    ) -> Result<Option<String>, String> {
778        let resp = t.send(&HttpRequest {
779            method: Method::Get,
780            url: format!(
781                "{API}/zones/{zone_id}/dns_records?type={}&name={name}",
782                rtype.as_str()
783            ),
784            headers: auth(token),
785            body: None,
786        })?;
787        let json = check_cf(&resp, "lookup dns_record")?;
788        Ok(json
789            .get("result")
790            .and_then(|v| v.as_array())
791            .and_then(|a| a.first())
792            .and_then(|r| r.get("id"))
793            .and_then(|v| v.as_str())
794            .map(str::to_string))
795    }
796
797    fn auth(token: &str) -> Vec<(String, String)> {
798        vec![
799            ("authorization".into(), format!("Bearer {token}")),
800            ("content-type".into(), "application/json".into()),
801        ]
802    }
803
804    /// Cloudflare answers 200 with `{"success":bool,"errors":[…]}`. Treat
805    /// `success:false` as an error even on HTTP 200.
806    fn check_cf(resp: &HttpResponse, what: &str) -> Result<serde_json::Value, String> {
807        let json: serde_json::Value = serde_json::from_str(&resp.body)
808            .map_err(|e| format!("cloudflare {what}: response not JSON ({e})"))?;
809        if !resp.is_success()
810            || json.get("success").and_then(serde_json::Value::as_bool) != Some(true)
811        {
812            let errs = json
813                .get("errors")
814                .map(ToString::to_string)
815                .unwrap_or_default();
816            return Err(format!(
817                "cloudflare {what} failed ({}): {errs}",
818                resp.status
819            ));
820        }
821        Ok(json)
822    }
823
824    fn outcome(req: &ChangeRequest) -> Outcome {
825        Outcome {
826            provider: Provider::Cloudflare,
827            action: req.action,
828            name: req.name.clone(),
829            record_type: req.record_type,
830        }
831    }
832}
833
834// ─────────────────────────────────────────────────────────────────────
835// AWS Signature Version 4
836// ─────────────────────────────────────────────────────────────────────
837
838/// All inputs needed to sign one request. Pure — no IO — so it is unit
839/// tested against AWS's published example vector.
840pub struct Sigv4<'a> {
841    pub method: &'a str,
842    pub host: &'a str,
843    /// Already URI-encoded absolute path.
844    pub canonical_uri: &'a str,
845    /// Already-canonical query string (sorted, encoded). Empty for none.
846    pub canonical_query: &'a str,
847    pub payload: &'a [u8],
848    pub region: &'a str,
849    pub service: &'a str,
850    pub access_key: &'a str,
851    pub secret_key: &'a str,
852    pub session_token: Option<&'a str>,
853    pub amz: AmzDate,
854}
855
856impl Sigv4<'_> {
857    /// The `(name, value)` header pairs to attach to the outgoing request
858    /// (`x-amz-date`, optional `x-amz-security-token`, `authorization`).
859    /// The `host` header is not returned — the HTTP client sets it, and
860    /// it is folded into the signature here using `self.host`.
861    pub fn signed_headers(&self) -> Vec<(String, String)> {
862        let (authorization, _) = self.compute();
863        let mut headers = vec![("x-amz-date".to_string(), self.amz.iso.clone())];
864        if let Some(tok) = self.session_token {
865            headers.push(("x-amz-security-token".to_string(), tok.to_string()));
866        }
867        headers.push(("authorization".to_string(), authorization));
868        headers
869    }
870
871    /// Returns `(authorization_header, hex_signature)`.
872    fn compute(&self) -> (String, String) {
873        let payload_hash = sha256_hex(self.payload);
874
875        // Canonical + signed headers. host and x-amz-date are always
876        // signed; x-amz-security-token too when present. Sorted by name.
877        let mut signed: Vec<(String, String)> = vec![
878            ("host".to_string(), self.host.to_string()),
879            ("x-amz-date".to_string(), self.amz.iso.clone()),
880        ];
881        if let Some(tok) = self.session_token {
882            signed.push(("x-amz-security-token".to_string(), tok.to_string()));
883        }
884        signed.sort_by(|a, b| a.0.cmp(&b.0));
885
886        let canonical_headers: String = signed
887            .iter()
888            .map(|(k, v)| format!("{}:{}\n", k, v.trim()))
889            .collect();
890        let signed_headers = signed
891            .iter()
892            .map(|(k, _)| k.as_str())
893            .collect::<Vec<_>>()
894            .join(";");
895
896        let canonical_request = format!(
897            "{}\n{}\n{}\n{}\n{}\n{}",
898            self.method,
899            self.canonical_uri,
900            self.canonical_query,
901            canonical_headers,
902            signed_headers,
903            payload_hash
904        );
905
906        let scope = format!(
907            "{}/{}/{}/aws4_request",
908            self.amz.ymd, self.region, self.service
909        );
910        let string_to_sign = format!(
911            "AWS4-HMAC-SHA256\n{}\n{}\n{}",
912            self.amz.iso,
913            scope,
914            sha256_hex(canonical_request.as_bytes())
915        );
916
917        let signing_key = self.signing_key();
918        let signature = hex::encode(hmac(&signing_key, string_to_sign.as_bytes()));
919
920        let authorization = format!(
921            "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
922            self.access_key, scope, signed_headers, signature
923        );
924        (authorization, signature)
925    }
926
927    fn signing_key(&self) -> Vec<u8> {
928        let k_date = hmac(
929            format!("AWS4{}", self.secret_key).as_bytes(),
930            self.amz.ymd.as_bytes(),
931        );
932        let k_region = hmac(&k_date, self.region.as_bytes());
933        let k_service = hmac(&k_region, self.service.as_bytes());
934        hmac(&k_service, b"aws4_request")
935    }
936}
937
938fn sha256_hex(data: &[u8]) -> String {
939    let mut h = Sha256::new();
940    h.update(data);
941    hex::encode(h.finalize())
942}
943
944fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
945    let mut m = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts a key of any length");
946    m.update(data);
947    m.finalize().into_bytes().to_vec()
948}
949
950/// AWS request date in the two forms SigV4 needs: `YYYYMMDD` (scope) and
951/// `YYYYMMDDTHHMMSSZ` (x-amz-date). Built from a Unix timestamp with a
952/// pure civil-date conversion so it's deterministic + test-vector-able.
953#[derive(Debug, Clone, PartialEq, Eq)]
954pub struct AmzDate {
955    pub ymd: String,
956    pub iso: String,
957}
958
959impl AmzDate {
960    pub fn from_unix(secs: u64) -> Self {
961        let days = (secs / 86_400) as i64;
962        let rem = secs % 86_400;
963        let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
964        let (y, mo, d) = civil_from_days(days);
965        AmzDate {
966            ymd: format!("{y:04}{mo:02}{d:02}"),
967            iso: format!("{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z"),
968        }
969    }
970}
971
972/// Howard Hinnant's days→civil algorithm (`z` = days since 1970-01-01).
973fn civil_from_days(z: i64) -> (i64, u32, u32) {
974    let z = z + 719_468;
975    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
976    let doe = z - era * 146_097; // [0, 146096]
977    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
978    let y = yoe + era * 400;
979    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
980    let mp = (5 * doy + 2) / 153; // [0, 11]
981    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
982    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
983    (if m <= 2 { y + 1 } else { y }, m, d)
984}
985
986/// AWS path canonicalization: URI-encode every segment, keep `/`
987/// unencoded. Unreserved chars (`A-Za-z0-9-._~`) pass through.
988fn uri_encode_path(path: &str) -> String {
989    let mut out = String::with_capacity(path.len());
990    for b in path.bytes() {
991        match b {
992            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
993                out.push(b as char);
994            }
995            _ => out.push_str(&format!("%{b:02X}")),
996        }
997    }
998    out
999}
1000
1001// ─────────────────────────────────────────────────────────────────────
1002// Lisp arg helpers
1003// ─────────────────────────────────────────────────────────────────────
1004
1005/// Parsed keyword arguments: alternating `:key value` pairs in `args`.
1006struct Kwargs<'a> {
1007    map: HashMap<String, &'a Value>,
1008}
1009
1010impl<'a> Kwargs<'a> {
1011    fn parse(args: &'a [Value], fn_name: &str, sp: tatara_lisp::Span) -> Result<Self, EvalError> {
1012        if args.len() % 2 != 0 {
1013            return Err(EvalError::native_fn(
1014                fn_name,
1015                "expects keyword arguments — got an odd number of values",
1016                sp,
1017            ));
1018        }
1019        let mut map = HashMap::new();
1020        let mut i = 0;
1021        while i < args.len() {
1022            let key = match &args[i] {
1023                Value::Keyword(k) | Value::Symbol(k) | Value::Str(k) => k.as_ref().to_string(),
1024                other => {
1025                    return Err(EvalError::native_fn(
1026                        fn_name,
1027                        format!(
1028                            "argument {} must be a keyword like :provider, got {}",
1029                            i + 1,
1030                            other.type_name()
1031                        ),
1032                        sp,
1033                    ))
1034                }
1035            };
1036            map.insert(key, &args[i + 1]);
1037            i += 2;
1038        }
1039        Ok(Self { map })
1040    }
1041
1042    fn provider(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<Provider, EvalError> {
1043        let v = self.want("provider", fn_name, sp)?;
1044        let s = as_word(v).ok_or_else(|| {
1045            EvalError::native_fn(fn_name, ":provider must be a keyword/string", sp)
1046        })?;
1047        Provider::from_keyword(s)
1048            .ok_or_else(|| EvalError::native_fn(fn_name, format!("unknown :provider '{s}'"), sp))
1049    }
1050
1051    fn record_type(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<RecordType, EvalError> {
1052        let v = self.want("record-type", fn_name, sp)?;
1053        let s = as_word(v).ok_or_else(|| {
1054            EvalError::native_fn(fn_name, ":record-type must be a keyword/string", sp)
1055        })?;
1056        RecordType::from_keyword(s).ok_or_else(|| {
1057            EvalError::native_fn(fn_name, format!("unsupported :record-type '{s}'"), sp)
1058        })
1059    }
1060
1061    fn credentials(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<Credentials, EvalError> {
1062        let v = self.want("credentials", fn_name, sp)?;
1063        match v {
1064            Value::Map(m) => {
1065                let mut map = HashMap::new();
1066                for (k, val) in m.iter() {
1067                    if let (
1068                        Value::Str(ks) | Value::Symbol(ks) | Value::Keyword(ks),
1069                        Value::Str(vs),
1070                    ) = (k.to_value(), val)
1071                    {
1072                        map.insert(ks.as_ref().to_string(), vs.as_ref().to_string());
1073                    }
1074                }
1075                Ok(Credentials { map })
1076            }
1077            Value::Nil => Ok(Credentials::default()),
1078            other => Err(EvalError::native_fn(
1079                fn_name,
1080                format!(":credentials must be a map, got {}", other.type_name()),
1081                sp,
1082            )),
1083        }
1084    }
1085
1086    fn want(
1087        &self,
1088        key: &str,
1089        fn_name: &str,
1090        sp: tatara_lisp::Span,
1091    ) -> Result<&'a Value, EvalError> {
1092        self.map
1093            .get(key)
1094            .copied()
1095            .ok_or_else(|| EvalError::native_fn(fn_name, format!("missing required :{key}"), sp))
1096    }
1097
1098    fn want_str(
1099        &self,
1100        key: &str,
1101        fn_name: &str,
1102        sp: tatara_lisp::Span,
1103    ) -> Result<String, EvalError> {
1104        match self.want(key, fn_name, sp)? {
1105            Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => Ok(s.as_ref().to_string()),
1106            other => Err(EvalError::native_fn(
1107                fn_name,
1108                format!(":{key} must be a string, got {}", other.type_name()),
1109                sp,
1110            )),
1111        }
1112    }
1113
1114    fn opt_int(&self, key: &str) -> Option<i64> {
1115        match self.map.get(key)? {
1116            Value::Int(n) => Some(*n),
1117            Value::Str(s) => s.parse().ok(),
1118            _ => None,
1119        }
1120    }
1121
1122    fn opt_bool(&self, key: &str) -> Option<bool> {
1123        match self.map.get(key)? {
1124            Value::Bool(b) => Some(*b),
1125            _ => None,
1126        }
1127    }
1128}
1129
1130fn as_word(v: &Value) -> Option<&str> {
1131    match v {
1132        Value::Keyword(s) | Value::Symbol(s) | Value::Str(s) => Some(s.as_ref()),
1133        _ => None,
1134    }
1135}
1136
1137fn cred_required<'a>(v: Option<&'a str>, name: &str) -> Result<&'a str, String> {
1138    v.filter(|s| !s.is_empty())
1139        .ok_or_else(|| format!("missing credential '{name}' in :credentials map"))
1140}
1141
1142fn map_value(fields: &[(&str, Value)]) -> Value {
1143    let mut m = HashMap::new();
1144    for (k, v) in fields {
1145        m.insert(MapKey::Keyword(Arc::from(*k)), v.clone());
1146    }
1147    Value::Map(Arc::new(m))
1148}
1149
1150// ─────────────────────────────────────────────────────────────────────
1151// Tests
1152// ─────────────────────────────────────────────────────────────────────
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use std::cell::RefCell;
1158
1159    // ── AmzDate ──────────────────────────────────────────────────────
1160
1161    #[test]
1162    fn amz_date_epoch() {
1163        let d = AmzDate::from_unix(0);
1164        assert_eq!(d.ymd, "19700101");
1165        assert_eq!(d.iso, "19700101T000000Z");
1166    }
1167
1168    #[test]
1169    fn amz_date_aws_example_instant() {
1170        // 2015-08-30T12:36:00Z — the AWS SigV4 worked-example timestamp.
1171        let secs = 1_440_938_160;
1172        let d = AmzDate::from_unix(secs);
1173        assert_eq!(d.ymd, "20150830");
1174        assert_eq!(d.iso, "20150830T123600Z");
1175    }
1176
1177    #[test]
1178    fn amz_date_leap_day() {
1179        // 2020-02-29T23:59:59Z
1180        let d = AmzDate::from_unix(1_583_020_799);
1181        assert_eq!(d.iso, "20200229T235959Z");
1182    }
1183
1184    // ── SigV4 gold vectors ───────────────────────────────────────────
1185    //
1186    // AWS Signature Version 4 Test Suite "get-vanilla": GET / against
1187    // example.amazonaws.com, service "service", region us-east-1,
1188    // 20150830T123600Z, empty body, minimal signed set host;x-amz-date.
1189    // The expected signature is the published suite value, independently
1190    // re-derived with a Python stdlib (hashlib+hmac) reference impl.
1191    #[test]
1192    fn sigv4_matches_aws_get_vanilla_vector() {
1193        let sig = Sigv4 {
1194            method: "GET",
1195            host: "example.amazonaws.com",
1196            canonical_uri: "/",
1197            canonical_query: "",
1198            payload: b"",
1199            region: "us-east-1",
1200            service: "service",
1201            access_key: "AKIDEXAMPLE",
1202            secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1203            session_token: None,
1204            amz: AmzDate {
1205                ymd: "20150830".into(),
1206                iso: "20150830T123600Z".into(),
1207            },
1208        };
1209        let (authz, signature) = sig.compute();
1210        assert_eq!(
1211            signature,
1212            "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
1213        );
1214        assert_eq!(
1215            authz,
1216            "AWS4-HMAC-SHA256 \
1217             Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1218             SignedHeaders=host;x-amz-date, \
1219             Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
1220        );
1221    }
1222
1223    /// Pins the signer for the real Route53 shape: POST with an XML body
1224    /// against route53.amazonaws.com, service route53. Expected signature
1225    /// independently computed with the Python stdlib reference.
1226    #[test]
1227    fn sigv4_route53_post_vector() {
1228        let sig = Sigv4 {
1229            method: "POST",
1230            host: "route53.amazonaws.com",
1231            canonical_uri: "/2013-04-01/hostedzone/Z123/rrset/",
1232            canonical_query: "",
1233            payload: b"<x/>",
1234            region: "us-east-1",
1235            service: "route53",
1236            access_key: "AKIDEXAMPLE",
1237            secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1238            session_token: None,
1239            amz: AmzDate {
1240                ymd: "20150830".into(),
1241                iso: "20150830T123600Z".into(),
1242            },
1243        };
1244        let (_authz, signature) = sig.compute();
1245        assert_eq!(
1246            signature,
1247            "27c5bd71813cc981dab5360ffe92bb03008c96db0ece56c2a3b9acea5e19922a"
1248        );
1249    }
1250
1251    #[test]
1252    fn sigv4_session_token_is_signed() {
1253        let sig = Sigv4 {
1254            method: "POST",
1255            host: "route53.amazonaws.com",
1256            canonical_uri: "/2013-04-01/hostedzone/Z123/rrset/",
1257            canonical_query: "",
1258            payload: b"<x/>",
1259            region: "us-east-1",
1260            service: "route53",
1261            access_key: "AKID",
1262            secret_key: "SECRET",
1263            session_token: Some("FwoTOKEN=="),
1264            amz: AmzDate {
1265                ymd: "20260524".into(),
1266                iso: "20260524T000000Z".into(),
1267            },
1268        };
1269        let headers = sig.signed_headers();
1270        assert!(headers
1271            .iter()
1272            .any(|(k, v)| k == "x-amz-security-token" && v == "FwoTOKEN=="));
1273        let authz = headers.iter().find(|(k, _)| k == "authorization").unwrap();
1274        assert!(authz
1275            .1
1276            .contains("SignedHeaders=host;x-amz-date;x-amz-security-token"));
1277    }
1278
1279    // ── Route53 typed XML ────────────────────────────────────────────
1280
1281    #[test]
1282    fn change_batch_xml_upsert() {
1283        let xml = ChangeBatch {
1284            action: "UPSERT",
1285            name: "akeyless-saas.ab12cd34.pleme-dev.use1.quero.lol",
1286            rtype: "CNAME",
1287            ttl: 60,
1288            value: "tunnel.cfargotunnel.com",
1289        }
1290        .to_string();
1291        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
1292        assert!(xml.contains("<Action>UPSERT</Action>"));
1293        assert!(xml.contains("<Name>akeyless-saas.ab12cd34.pleme-dev.use1.quero.lol</Name>"));
1294        assert!(xml.contains("<Type>CNAME</Type>"));
1295        assert!(xml.contains("<TTL>60</TTL>"));
1296        assert!(xml.contains("<Value>tunnel.cfargotunnel.com</Value>"));
1297    }
1298
1299    #[test]
1300    fn change_batch_xml_escapes_values() {
1301        let xml = ChangeBatch {
1302            action: "UPSERT",
1303            name: "a&b",
1304            rtype: "TXT",
1305            ttl: 300,
1306            value: "x<y>\"z\"",
1307        }
1308        .to_string();
1309        assert!(xml.contains("<Name>a&amp;b</Name>"));
1310        assert!(xml.contains("&lt;y&gt;"));
1311        assert!(xml.contains("&quot;z&quot;"));
1312    }
1313
1314    // ── MockTransport ────────────────────────────────────────────────
1315
1316    #[derive(Default)]
1317    struct MockTransport {
1318        responses: RefCell<Vec<HttpResponse>>,
1319        seen: RefCell<Vec<(Method, String, Vec<(String, String)>, Option<String>)>>,
1320    }
1321    impl MockTransport {
1322        fn with(responses: Vec<(u16, &str)>) -> Self {
1323            Self {
1324                responses: RefCell::new(
1325                    responses
1326                        .into_iter()
1327                        .rev()
1328                        .map(|(s, b)| HttpResponse {
1329                            status: s,
1330                            body: b.to_string(),
1331                        })
1332                        .collect(),
1333                ),
1334                seen: RefCell::new(Vec::new()),
1335            }
1336        }
1337    }
1338    impl DnsTransport for MockTransport {
1339        fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String> {
1340            self.seen.borrow_mut().push((
1341                req.method,
1342                req.url.clone(),
1343                req.headers.clone(),
1344                req.body.clone(),
1345            ));
1346            self.responses
1347                .borrow_mut()
1348                .pop()
1349                .ok_or_else(|| "mock: no response queued".to_string())
1350        }
1351    }
1352
1353    fn route53_creds() -> Credentials {
1354        let mut map = HashMap::new();
1355        map.insert("access-key-id".to_string(), "AKIDEXAMPLE".to_string());
1356        map.insert(
1357            "secret-access-key".to_string(),
1358            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
1359        );
1360        Credentials { map }
1361    }
1362
1363    #[test]
1364    fn route53_upsert_signs_and_posts() {
1365        let t = MockTransport::with(vec![(200, "<ChangeResourceRecordSetsResponse/>")]);
1366        let req = ChangeRequest {
1367            provider: Provider::Route53,
1368            zone_id: "/hostedzone/Z3M3LMPEXAMPLE".into(),
1369            credentials: route53_creds(),
1370            record_type: RecordType::Cname,
1371            name: "x.pleme-dev.use1.quero.lol".into(),
1372            value: "tunnel.cfargotunnel.com".into(),
1373            ttl: 60,
1374            proxied: false,
1375            action: Action::Upsert,
1376        };
1377        let outcome = route53::change(&t, &req, 1_440_938_160).expect("upsert ok");
1378        assert_eq!(outcome.provider, Provider::Route53);
1379
1380        let seen = t.seen.borrow();
1381        assert_eq!(seen.len(), 1);
1382        let (method, url, headers, body) = &seen[0];
1383        assert_eq!(*method, Method::Post);
1384        // Leading /hostedzone/ stripped from the operator-pasted zone id.
1385        assert_eq!(
1386            url,
1387            "https://route53.amazonaws.com/2013-04-01/hostedzone/Z3M3LMPEXAMPLE/rrset/"
1388        );
1389        assert!(headers
1390            .iter()
1391            .any(|(k, v)| k == "authorization" && v.starts_with("AWS4-HMAC-SHA256 ")));
1392        assert!(headers.iter().any(|(k, _)| k == "x-amz-date"));
1393        assert!(body.as_ref().unwrap().contains("<Action>UPSERT</Action>"));
1394    }
1395
1396    #[test]
1397    fn route53_upsert_surfaces_api_error() {
1398        let t = MockTransport::with(vec![(
1399            403,
1400            "<ErrorResponse><Error><Code>AccessDenied</Code></Error></ErrorResponse>",
1401        )]);
1402        let req = ChangeRequest {
1403            provider: Provider::Route53,
1404            zone_id: "Z1".into(),
1405            credentials: route53_creds(),
1406            record_type: RecordType::A,
1407            name: "x".into(),
1408            value: "1.2.3.4".into(),
1409            ttl: 60,
1410            proxied: false,
1411            action: Action::Upsert,
1412        };
1413        let err = route53::change(&t, &req, 0).unwrap_err();
1414        assert!(err.contains("403"), "expected status in error: {err}");
1415        assert!(
1416            err.contains("AccessDenied"),
1417            "expected body in error: {err}"
1418        );
1419    }
1420
1421    #[test]
1422    fn route53_missing_creds_is_typed_error() {
1423        let t = MockTransport::with(vec![]);
1424        let req = ChangeRequest {
1425            provider: Provider::Route53,
1426            zone_id: "Z1".into(),
1427            credentials: Credentials::default(),
1428            record_type: RecordType::A,
1429            name: "x".into(),
1430            value: "1.2.3.4".into(),
1431            ttl: 60,
1432            proxied: false,
1433            action: Action::Upsert,
1434        };
1435        let err = route53::change(&t, &req, 0).unwrap_err();
1436        assert!(err.contains("access-key-id"), "{err}");
1437    }
1438
1439    #[test]
1440    fn cloudflare_upsert_creates_when_absent() {
1441        // 1st call: lookup → empty result. 2nd: create → success.
1442        let t = MockTransport::with(vec![
1443            (200, r#"{"success":true,"errors":[],"result":[]}"#),
1444            (200, r#"{"success":true,"errors":[],"result":{"id":"new"}}"#),
1445        ]);
1446        let mut map = HashMap::new();
1447        map.insert("api-token".to_string(), "cf-token".to_string());
1448        let req = ChangeRequest {
1449            provider: Provider::Cloudflare,
1450            zone_id: "zone1".into(),
1451            credentials: Credentials { map },
1452            record_type: RecordType::Cname,
1453            name: "x.quero.lol".into(),
1454            value: "target.example".into(),
1455            ttl: 60,
1456            proxied: false,
1457            action: Action::Upsert,
1458        };
1459        cloudflare::change(&t, &req).expect("cf upsert ok");
1460        let seen = t.seen.borrow();
1461        assert_eq!(seen.len(), 2);
1462        assert_eq!(seen[0].0, Method::Get); // lookup
1463        assert_eq!(seen[1].0, Method::Post); // create (no existing id)
1464        assert!(seen[0]
1465            .2
1466            .iter()
1467            .any(|(k, v)| k == "authorization" && v == "Bearer cf-token"));
1468    }
1469
1470    #[test]
1471    fn cloudflare_upsert_updates_when_present() {
1472        let t = MockTransport::with(vec![
1473            (
1474                200,
1475                r#"{"success":true,"errors":[],"result":[{"id":"abc123"}]}"#,
1476            ),
1477            (
1478                200,
1479                r#"{"success":true,"errors":[],"result":{"id":"abc123"}}"#,
1480            ),
1481        ]);
1482        let mut map = HashMap::new();
1483        map.insert("token".to_string(), "cf-token".to_string());
1484        let req = ChangeRequest {
1485            provider: Provider::Cloudflare,
1486            zone_id: "zone1".into(),
1487            credentials: Credentials { map },
1488            record_type: RecordType::A,
1489            name: "x.quero.lol".into(),
1490            value: "1.2.3.4".into(),
1491            ttl: 120,
1492            proxied: true,
1493            action: Action::Upsert,
1494        };
1495        cloudflare::change(&t, &req).expect("cf update ok");
1496        let seen = t.seen.borrow();
1497        assert_eq!(seen[1].0, Method::Put); // existing id → PUT
1498        assert!(seen[1].1.ends_with("/dns_records/abc123"));
1499    }
1500
1501    // ── parsing + dispatch ───────────────────────────────────────────
1502
1503    #[test]
1504    fn record_type_accepts_both_keyword_dialects() {
1505        for s in ["A", "a"] {
1506            assert_eq!(RecordType::from_keyword(s), Some(RecordType::A));
1507        }
1508        for s in ["AAAA", "aaaa", "a-a-a-a"] {
1509            assert_eq!(RecordType::from_keyword(s), Some(RecordType::Aaaa));
1510        }
1511        for s in ["CNAME", "cname", "c-n-a-m-e"] {
1512            assert_eq!(RecordType::from_keyword(s), Some(RecordType::Cname));
1513        }
1514        for s in ["TXT", "t-x-t"] {
1515            assert_eq!(RecordType::from_keyword(s), Some(RecordType::Txt));
1516        }
1517        assert_eq!(RecordType::from_keyword("mx"), None);
1518    }
1519
1520    #[test]
1521    fn unimplemented_providers_error_not_silent() {
1522        let t = MockTransport::with(vec![]);
1523        for p in [Provider::Hetzner, Provider::Gcp] {
1524            let req = ChangeRequest {
1525                provider: p,
1526                zone_id: "z".into(),
1527                credentials: Credentials::default(),
1528                record_type: RecordType::A,
1529                name: "x".into(),
1530                value: "1.2.3.4".into(),
1531                ttl: 60,
1532                proxied: false,
1533                action: Action::Upsert,
1534            };
1535            let err = dns_change(&t, &req).unwrap_err();
1536            assert!(err.contains("not implemented"), "{err}");
1537        }
1538    }
1539
1540    #[test]
1541    fn txt_values_get_route53_quoting() {
1542        let xml = ChangeBatch {
1543            action: "UPSERT",
1544            name: "_acme.quero.lol",
1545            rtype: "TXT",
1546            ttl: 60,
1547            value: &route53::formatted_value(RecordType::Txt, "token123"),
1548        }
1549        .to_string();
1550        assert!(xml.contains("<Value>&quot;token123&quot;</Value>"), "{xml}");
1551    }
1552
1553    #[test]
1554    fn route53_list_parses_rrsets() {
1555        let xml = r#"<ListResourceRecordSetsResponse>
1556          <ResourceRecordSets>
1557            <ResourceRecordSet><Name>a.quero.lol.</Name><Type>A</Type><TTL>60</TTL>
1558              <ResourceRecords><ResourceRecord><Value>1.2.3.4</Value></ResourceRecord></ResourceRecords>
1559            </ResourceRecordSet>
1560            <ResourceRecordSet><Name>cn.quero.lol.</Name><Type>CNAME</Type><TTL>300</TTL>
1561              <ResourceRecords><ResourceRecord><Value>tgt.example</Value></ResourceRecord></ResourceRecords>
1562            </ResourceRecordSet>
1563          </ResourceRecordSets>
1564        </ListResourceRecordSetsResponse>"#;
1565        let t = MockTransport::with(vec![(200, xml)]);
1566        let recs = route53::list(&t, "Z1", &route53_creds(), 0).unwrap();
1567        assert_eq!(recs.len(), 2);
1568        assert_eq!(recs[0].name, "a.quero.lol.");
1569        assert_eq!(recs[0].record_type, "A");
1570        assert_eq!(recs[0].value, "1.2.3.4");
1571        assert_eq!(recs[0].ttl, Some(60));
1572        assert_eq!(recs[1].record_type, "CNAME");
1573    }
1574}