1use 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
55pub 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
88fn 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#[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 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 fn route53_verb(self) -> &'static str {
197 match self {
198 Self::Upsert => "UPSERT",
199 Self::Delete => "DELETE",
200 }
201 }
202}
203
204#[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 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#[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#[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#[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
340pub trait DnsTransport {
343 fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String>;
344}
345
346pub 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
411fn 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
450mod 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 fn bare_zone_id(z: &str) -> &str {
557 z.trim_start_matches("/hostedzone/").trim_start_matches('/')
558 }
559
560 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 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
616struct 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
648struct 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("&")?,
656 '<' => f.write_str("<")?,
657 '>' => f.write_str(">")?,
658 '"' => f.write_str(""")?,
659 '\'' => f.write_str("'")?,
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("<", "<")
669 .replace(">", ">")
670 .replace(""", "\"")
671 .replace("'", "'")
672 .replace("&", "&")
673}
674
675mod 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 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 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
834pub struct Sigv4<'a> {
841 pub method: &'a str,
842 pub host: &'a str,
843 pub canonical_uri: &'a str,
845 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 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 fn compute(&self) -> (String, String) {
873 let payload_hash = sha256_hex(self.payload);
874
875 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#[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
972fn 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
979 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
984}
985
986fn 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
1001struct 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#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use std::cell::RefCell;
1158
1159 #[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 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 let d = AmzDate::from_unix(1_583_020_799);
1181 assert_eq!(d.iso, "20200229T235959Z");
1182 }
1183
1184 #[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 #[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 #[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&b</Name>"));
1310 assert!(xml.contains("<y>"));
1311 assert!(xml.contains(""z""));
1312 }
1313
1314 #[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 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 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); assert_eq!(seen[1].0, Method::Post); 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); assert!(seen[1].1.ends_with("/dns_records/abc123"));
1499 }
1500
1501 #[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>"token123"</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}