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