Skip to main content

didbot_dns/
aws_fake.rs

1//! An in-process stand-in for the AWS surface this deployment talks to.
2//!
3//! The AWS interactions in this workspace are its own code end to end:
4//! [`crate::route53`] signs its own SigV4 requests and reads IMDSv2 itself,
5//! with no AWS SDK anywhere in the dependency graph. That makes every one of
6//! them reachable from a test with no cloud account, provided there is
7//! something on the other end of the wire — which is what this module is.
8//!
9//! Two pieces, matching the two AWS services that code calls:
10//!
11//! * [`FakeImds`] answers the instance metadata service's credential read,
12//!   hands out a fresh access key id per read, and can expire the credential
13//!   it last issued — on command, or after a set number of API calls, so a
14//!   single long-running operation can outlive the credential it started
15//!   with.
16//! * [`FakeAws`] is a Route53 hosted zone that holds record sets, applies
17//!   `ChangeResourceRecordSets` batches atomically, answers
18//!   `ListResourceRecordSets` a page at a time, and takes a change through
19//!   `PENDING` before `INSYNC`. It refuses what the real service refuses: a
20//!   `CREATE` onto a name and type it already holds, a `DELETE` whose values
21//!   do not match, and a batch that would push the zone past its record-set
22//!   quota. It can be told to throttle, to return a server error, or to drop
23//!   the connection for a set number of calls.
24//!
25//! [`route53_provider`] joins them: a [`Route53Dns`] whose transport is a
26//! [`FakeAws`], with retry and polling intervals wound down to milliseconds
27//! so a test that drives a retry or a `PENDING` change resolves it in
28//! ordinary test time. Nothing here sleeps for longer than that, and no
29//! outcome depends on how long anything took.
30//!
31//! Behind the `aws-fake` feature, which a crate turns on as a
32//! dev-dependency — `didbot-dns = { workspace = true, features =
33//! ["aws-fake"] }` under `[dev-dependencies]` — so that a production build
34//! links none of it.
35//!
36//! The ACME side of a deployment has its own fake, `FakeAcmeServer` in
37//! `didbot-tls`'s `tests/support`, which speaks `instant_acme`'s HTTP client
38//! trait rather than AWS's wire format. A DNS-01 test wants both: that one
39//! for the CA, this one for the zone the challenge is written into.
40
41use std::collections::BTreeMap;
42use std::sync::{Arc, Mutex};
43use std::time::Duration;
44
45use time::OffsetDateTime;
46
47use crate::route53::{
48    CredentialFetch, ImdsError, InstanceCredentials, Response, RetryPolicy, Route53Credentials,
49    Route53Dns, TimedCredentials, Transport,
50};
51
52// ---------------------------------------------------------------------------
53// IMDSv2
54// ---------------------------------------------------------------------------
55
56/// The instance metadata service, as far as [`InstanceCredentials`] is
57/// concerned: a source of temporary credentials, each with a stated expiry.
58///
59/// Every read issues a new access key id, so a test can tell which
60/// credential a request was signed with, and [`FakeAws`] refuses a request
61/// signed with one this holder no longer considers live — the way Route53
62/// answers `ExpiredTokenException` to a signature made with a lapsed
63/// instance-profile credential.
64pub struct FakeImds {
65    state: Mutex<ImdsState>,
66    lifetime: time::Duration,
67}
68
69struct ImdsState {
70    /// Every access key id issued, with the moment it stops being accepted.
71    issued: Vec<(String, OffsetDateTime)>,
72    reads: u64,
73    /// Answers this many reads with an error before issuing again.
74    fail_next: u32,
75}
76
77impl FakeImds {
78    /// A metadata service whose credentials last six hours — long enough
79    /// that [`InstanceCredentials::current`] never refreshes on its own, so
80    /// a test that wants a refresh asks for one with
81    /// [`FakeAws::expire_credential`] or
82    /// [`FakeAws::expire_credential_after`].
83    #[must_use]
84    pub fn new() -> Arc<Self> {
85        Self::with_lifetime(time::Duration::hours(6))
86    }
87
88    /// A metadata service whose credentials last `lifetime`.
89    #[must_use]
90    pub fn with_lifetime(lifetime: time::Duration) -> Arc<Self> {
91        Arc::new(Self {
92            state: Mutex::new(ImdsState {
93                issued: Vec::new(),
94                reads: 0,
95                fail_next: 0,
96            }),
97            lifetime,
98        })
99    }
100
101    /// A credential holder that reads this service, with its first read
102    /// already made — the same shape
103    /// [`InstanceCredentials::from_instance_metadata`] returns.
104    ///
105    /// # Errors
106    ///
107    /// Whatever the first read answers, so a test can start from a metadata
108    /// service that refuses.
109    pub fn credentials(self: &Arc<Self>) -> Result<Arc<InstanceCredentials>, ImdsError> {
110        InstanceCredentials::with_fetch(Box::new(Arc::clone(self))).map(Arc::new)
111    }
112
113    /// How many times the credential endpoint has been read.
114    #[must_use]
115    pub fn reads(&self) -> u64 {
116        self.guard().reads
117    }
118
119    /// The access key ids this service has issued, oldest first.
120    #[must_use]
121    pub fn issued(&self) -> Vec<String> {
122        self.guard()
123            .issued
124            .iter()
125            .map(|(id, _)| id.clone())
126            .collect()
127    }
128
129    /// Answers the next `count` reads with an error instead of a credential.
130    pub fn fail_next(&self, count: u32) {
131        self.guard().fail_next = count;
132    }
133
134    /// Stops accepting the access key id this service issued most recently.
135    ///
136    /// This is the service's half of an expiry: a request still signed with
137    /// that key id is refused from here on. The holder's half —
138    /// [`InstanceCredentials`] noticing that what it holds is past its
139    /// stated expiry and reading again — is
140    /// [`FakeAws::expire_credential`], which does both.
141    pub fn expire_current(&self) {
142        let mut state = self.guard();
143        if let Some((_, expires_at)) = state.issued.last_mut() {
144            *expires_at = OffsetDateTime::now_utc() - time::Duration::seconds(1);
145        }
146    }
147
148    /// Whether a request signed with `access_key_id` is still accepted.
149    #[must_use]
150    pub fn is_live(&self, access_key_id: &str) -> bool {
151        let now = OffsetDateTime::now_utc();
152        self.guard()
153            .issued
154            .iter()
155            .any(|(id, expires_at)| id == access_key_id && *expires_at > now)
156    }
157
158    fn guard(&self) -> std::sync::MutexGuard<'_, ImdsState> {
159        self.state
160            .lock()
161            .unwrap_or_else(|poisoned| poisoned.into_inner())
162    }
163}
164
165impl CredentialFetch for Arc<FakeImds> {
166    fn fetch(&self) -> Result<TimedCredentials, ImdsError> {
167        let mut state = self.guard();
168        state.reads += 1;
169        if state.fail_next > 0 {
170            state.fail_next -= 1;
171            return Err(ImdsError::Response(
172                "instance metadata service is unreachable".to_string(),
173            ));
174        }
175        let serial = state.issued.len();
176        let access_key_id = format!("ASIAFAKEKEY{serial:04}");
177        let expires_at = OffsetDateTime::now_utc() + self.lifetime;
178        state.issued.push((access_key_id.clone(), expires_at));
179        Ok(TimedCredentials {
180            credentials: Route53Credentials {
181                access_key_id,
182                secret_access_key: format!("fake-secret-{serial:04}"),
183                session_token: Some(format!("fake-session-token-{serial:04}")),
184            },
185            expires_at: Some(expires_at),
186        })
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Route53
192// ---------------------------------------------------------------------------
193
194/// One record set in the fake hosted zone.
195#[derive(Clone, Debug)]
196struct RecordSet {
197    ttl: u32,
198    values: Vec<String>,
199    /// `false` while the change that created it is still `PENDING`: the zone
200    /// has accepted it, and `ListResourceRecordSets` does not answer with it
201    /// yet.
202    visible: bool,
203    /// `true` while a `DELETE` of it is still `PENDING`. It is still live —
204    /// and still listed — until that change reaches `INSYNC`.
205    deleting: bool,
206}
207
208/// A record set's identity, and the order a zone listing walks: name first,
209/// then type.
210type Key = (String, String);
211
212/// A change accepted by the zone and not yet `INSYNC`.
213struct PendingChange {
214    polls_left: u32,
215    /// The record sets this change finalizes when it lands.
216    keys: Vec<Key>,
217}
218
219struct ZoneState {
220    sets: BTreeMap<Key, RecordSet>,
221    changes: BTreeMap<String, PendingChange>,
222    next_change: u64,
223    calls: Vec<(String, String)>,
224    call_count: u64,
225    change_batches: u64,
226    throttle_next: u32,
227    fail_next: u32,
228    server_error_next: u32,
229    pending_polls: u32,
230    max_record_sets: u64,
231    page_size: usize,
232    expire_credential_after: Option<u64>,
233}
234
235/// A Route53 hosted zone, in process.
236///
237/// Holds record sets, applies change batches atomically, paginates a
238/// listing, and takes each change through `PENDING` before `INSYNC` — a
239/// record set created by a change still in flight is in the zone, and is not
240/// in a listing, which is what makes "the publish waited" an observable fact
241/// rather than an internal one.
242pub struct FakeAws {
243    hosted_zone_id: String,
244    state: Mutex<ZoneState>,
245    credentials: Mutex<Option<(Arc<InstanceCredentials>, Arc<FakeImds>)>>,
246}
247
248impl FakeAws {
249    /// An empty hosted zone with AWS's default 10,000 record-set quota, a
250    /// 100-record listing page, and changes that are `INSYNC` as soon as
251    /// they are accepted.
252    #[must_use]
253    pub fn new(hosted_zone_id: impl Into<String>) -> Arc<Self> {
254        Arc::new(Self {
255            hosted_zone_id: hosted_zone_id.into(),
256            state: Mutex::new(ZoneState {
257                sets: BTreeMap::new(),
258                changes: BTreeMap::new(),
259                next_change: 0,
260                calls: Vec::new(),
261                call_count: 0,
262                change_batches: 0,
263                throttle_next: 0,
264                fail_next: 0,
265                server_error_next: 0,
266                pending_polls: 0,
267                max_record_sets: 10_000,
268                page_size: 100,
269                expire_credential_after: None,
270            }),
271            credentials: Mutex::new(None),
272        })
273    }
274
275    /// The hosted zone id this zone answers for.
276    #[must_use]
277    pub fn hosted_zone_id(&self) -> &str {
278        &self.hosted_zone_id
279    }
280
281    /// Requires every request to carry a live credential from `imds`, and
282    /// refuses one signed with a key id that has expired — Route53's
283    /// `ExpiredTokenException`.
284    pub fn require_credentials(&self, credentials: Arc<InstanceCredentials>, imds: Arc<FakeImds>) {
285        *self
286            .credentials
287            .lock()
288            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((credentials, imds));
289    }
290
291    /// Expires the credential in hand once this many API calls have been
292    /// served, so an operation already in flight is the thing that has to
293    /// refresh. Requires [`Self::require_credentials`].
294    pub fn expire_credential_after(&self, calls: u64) {
295        self.guard().expire_credential_after = Some(calls);
296    }
297
298    /// Expires the credential in hand right now: the holder reads instance
299    /// metadata again on its next signature, and any request still carrying
300    /// the old access key id is refused. Requires
301    /// [`Self::require_credentials`].
302    pub fn expire_credential(&self) {
303        let held = self
304            .credentials
305            .lock()
306            .unwrap_or_else(|poisoned| poisoned.into_inner());
307        if let Some((credentials, imds)) = held.as_ref() {
308            credentials.expire_held();
309            imds.expire_current();
310        }
311    }
312
313    /// How many `GetChange` polls a change answers `PENDING` to before it is
314    /// `INSYNC`. Zero — the default — makes every change land as it is
315    /// accepted.
316    pub fn set_pending_polls(&self, polls: u32) {
317        self.guard().pending_polls = polls;
318    }
319
320    /// The zone's record-set quota. A batch that would take the zone past it
321    /// is refused whole, with `LimitsExceeded`.
322    pub fn set_max_record_sets(&self, max: u64) {
323        self.guard().max_record_sets = max;
324    }
325
326    /// How many record sets one `ListResourceRecordSets` page carries.
327    pub fn set_page_size(&self, size: usize) {
328        self.guard().page_size = size.max(1);
329    }
330
331    /// Answers the next `count` calls with `Throttling`.
332    pub fn throttle_next(&self, count: u32) {
333        self.guard().throttle_next = count;
334    }
335
336    /// Answers the next `count` calls with an HTTP 500.
337    pub fn server_error_next(&self, count: u32) {
338        self.guard().server_error_next = count;
339    }
340
341    /// Drops the connection on the next `count` calls, before any response.
342    pub fn fail_next(&self, count: u32) {
343        self.guard().fail_next = count;
344    }
345
346    /// Puts a record set into the zone directly, visible immediately and
347    /// without an API call — a zone that already held records when this
348    /// process started.
349    pub fn seed(&self, name: &str, rtype: &str, ttl: u32, values: &[&str]) {
350        let mut state = self.guard();
351        state.sets.insert(
352            (normalize(name), rtype.to_ascii_uppercase()),
353            RecordSet {
354                ttl,
355                values: values.iter().map(|v| (*v).to_string()).collect(),
356                visible: true,
357                deleting: false,
358            },
359        );
360    }
361
362    /// Every record set a listing would answer with, as `(name, type,
363    /// values)`, in zone order.
364    #[must_use]
365    pub fn record_sets(&self) -> Vec<(String, String, Vec<String>)> {
366        self.guard()
367            .sets
368            .iter()
369            .filter(|(_, set)| set.visible)
370            .map(|((name, rtype), set)| (name.clone(), rtype.clone(), set.values.clone()))
371            .collect()
372    }
373
374    /// Whether the zone answers for `name` at `rtype` right now.
375    #[must_use]
376    pub fn holds(&self, name: &str, rtype: &str) -> bool {
377        self.guard()
378            .sets
379            .get(&(normalize(name), rtype.to_ascii_uppercase()))
380            .is_some_and(|set| set.visible)
381    }
382
383    /// Every record set the zone holds, listed or not — including one whose
384    /// creating change has not reached `INSYNC`.
385    #[must_use]
386    pub fn record_set_count(&self) -> usize {
387        self.guard().sets.len()
388    }
389
390    /// Every API call served, as `(method, path)`.
391    #[must_use]
392    pub fn calls(&self) -> Vec<(String, String)> {
393        self.guard().calls.clone()
394    }
395
396    /// How many `ChangeResourceRecordSets` requests the zone has been sent,
397    /// counted before anything else — retries, throttled attempts and
398    /// refusals included, which is what makes a duplicated write visible.
399    #[must_use]
400    pub fn change_batches(&self) -> u64 {
401        self.guard().change_batches
402    }
403
404    fn guard(&self) -> std::sync::MutexGuard<'_, ZoneState> {
405        self.state
406            .lock()
407            .unwrap_or_else(|poisoned| poisoned.into_inner())
408    }
409
410    /// The credential check, run before any request is dispatched.
411    fn check_credentials(&self, call_count: u64, expire_after: Option<u64>) -> Option<Response> {
412        let held = self
413            .credentials
414            .lock()
415            .unwrap_or_else(|poisoned| poisoned.into_inner());
416        let (credentials, imds) = held.as_ref()?;
417        if expire_after.is_some_and(|after| call_count > after) {
418            credentials.expire_held();
419            imds.expire_current();
420        }
421        match credentials.current() {
422            Ok(current) if imds.is_live(&current.access_key_id) => None,
423            Ok(_) => Some(Response {
424                status: 403,
425                body: error_body(
426                    "ExpiredTokenException",
427                    "The security token included in the request is expired",
428                ),
429            }),
430            Err(err) => Some(Response {
431                status: 403,
432                body: error_body("InvalidClientTokenId", &err.to_string()),
433            }),
434        }
435    }
436}
437
438impl Transport for Arc<FakeAws> {
439    fn call(
440        &self,
441        method: &str,
442        path: &str,
443        query: &[(String, String)],
444        body: &str,
445    ) -> Result<Response, String> {
446        let (call_count, expire_after) = {
447            let mut state = self.guard();
448            state.calls.push((method.to_string(), path.to_string()));
449            state.call_count += 1;
450            if method == "POST" && path.ends_with("/rrset") {
451                state.change_batches += 1;
452            }
453            (state.call_count, state.expire_credential_after)
454        };
455
456        if let Some(refusal) = self.check_credentials(call_count, expire_after) {
457            return Ok(refusal);
458        }
459
460        let mut state = self.guard();
461        if state.fail_next > 0 {
462            state.fail_next -= 1;
463            return Err("connection reset by peer".to_string());
464        }
465        if state.throttle_next > 0 {
466            state.throttle_next -= 1;
467            return Ok(Response {
468                status: 400,
469                body: error_body("Throttling", "Rate exceeded"),
470            });
471        }
472        if state.server_error_next > 0 {
473            state.server_error_next -= 1;
474            return Ok(Response {
475                status: 500,
476                body: error_body("InternalError", "We encountered an internal error"),
477            });
478        }
479
480        if path.ends_with("/rrset") {
481            match method {
482                "POST" => return Ok(state.apply_change_batch(body)),
483                "GET" => return Ok(state.list_record_sets(query)),
484                _ => {}
485            }
486        }
487        if method == "GET" {
488            if let Some((_, change_id)) = path.rsplit_once("/change/") {
489                return Ok(state.get_change(change_id));
490            }
491        }
492        Ok(Response {
493            status: 404,
494            body: error_body("NoSuchElement", "no such route on the fake zone"),
495        })
496    }
497}
498
499impl ZoneState {
500    fn apply_change_batch(&mut self, body: &str) -> Response {
501        let changes = match parse_changes(body) {
502            Ok(changes) => changes,
503            Err(message) => {
504                return Response {
505                    status: 400,
506                    body: error_body("InvalidChangeBatch", &message),
507                }
508            }
509        };
510
511        // Route53 validates a batch whole and applies it whole. A refusal
512        // below leaves the zone exactly as it was.
513        let mut created = 0u64;
514        for change in &changes {
515            let key = (normalize(&change.name), change.rtype.clone());
516            let live = self.sets.get(&key).filter(|set| !set.deleting);
517            match change.action.as_str() {
518                "CREATE" => {
519                    if live.is_some() {
520                        return Response {
521                            status: 400,
522                            body: error_body(
523                                "InvalidChangeBatch",
524                                &format!(
525                                    "Tried to create resource record set [name='{}.', type='{}'] but it already exists",
526                                    key.0, key.1
527                                ),
528                            ),
529                        };
530                    }
531                    created += 1;
532                }
533                "DELETE" => match live {
534                    None => {
535                        return Response {
536                            status: 400,
537                            body: error_body(
538                                "InvalidChangeBatch",
539                                &format!(
540                                    "Tried to delete resource record set [name='{}.', type='{}'] but it was not found",
541                                    key.0, key.1
542                                ),
543                            ),
544                        }
545                    }
546                    Some(set) if set.values != change.values => {
547                        return Response {
548                            status: 400,
549                            body: error_body(
550                                "InvalidChangeBatch",
551                                &format!(
552                                    "Tried to delete resource record set [name='{}.', type='{}'] but the values provided do not match the current values",
553                                    key.0, key.1
554                                ),
555                            ),
556                        }
557                    }
558                    Some(_) => {}
559                },
560                "UPSERT" => {
561                    if live.is_none() {
562                        created += 1;
563                    }
564                }
565                other => {
566                    return Response {
567                        status: 400,
568                        body: error_body(
569                            "InvalidChangeBatch",
570                            &format!("unsupported action {other}"),
571                        ),
572                    }
573                }
574            }
575        }
576
577        if self.sets.len() as u64 + created > self.max_record_sets {
578            return Response {
579                status: 400,
580                body: error_body(
581                    "LimitsExceeded",
582                    &format!(
583                        "the hosted zone has reached its limit of {} resource record sets",
584                        self.max_record_sets
585                    ),
586                ),
587            };
588        }
589
590        self.next_change += 1;
591        let change_id = format!("C{:08}", self.next_change);
592        let polls = self.pending_polls;
593        let mut keys = Vec::with_capacity(changes.len());
594        for change in changes {
595            let key = (normalize(&change.name), change.rtype.clone());
596            keys.push(key.clone());
597            match change.action.as_str() {
598                "DELETE" => {
599                    if polls == 0 {
600                        self.sets.remove(&key);
601                    } else if let Some(set) = self.sets.get_mut(&key) {
602                        set.deleting = true;
603                    }
604                }
605                _ => {
606                    self.sets.insert(
607                        key,
608                        RecordSet {
609                            ttl: change.ttl,
610                            values: change.values,
611                            visible: polls == 0,
612                            deleting: false,
613                        },
614                    );
615                }
616            }
617        }
618        if polls > 0 {
619            self.changes.insert(
620                change_id.clone(),
621                PendingChange {
622                    polls_left: polls,
623                    keys,
624                },
625            );
626        }
627
628        let status = if polls == 0 { "INSYNC" } else { "PENDING" };
629        Response {
630            status: 200,
631            body: format!(
632                "<ChangeResourceRecordSetsResponse><ChangeInfo><Id>/change/{change_id}</Id>\
633                 <Status>{status}</Status></ChangeInfo></ChangeResourceRecordSetsResponse>"
634            ),
635        }
636    }
637
638    fn get_change(&mut self, change_id: &str) -> Response {
639        let status = match self.changes.get_mut(change_id) {
640            None => "INSYNC",
641            Some(change) if change.polls_left > 0 => {
642                change.polls_left -= 1;
643                "PENDING"
644            }
645            Some(_) => {
646                let change = self
647                    .changes
648                    .remove(change_id)
649                    .expect("matched immediately above");
650                for key in change.keys {
651                    match self.sets.get_mut(&key) {
652                        Some(set) if set.deleting => {
653                            self.sets.remove(&key);
654                        }
655                        Some(set) => set.visible = true,
656                        None => {}
657                    }
658                }
659                "INSYNC"
660            }
661        };
662        Response {
663            status: 200,
664            body: format!(
665                "<GetChangeResponse><ChangeInfo><Id>/change/{change_id}</Id>\
666                 <Status>{status}</Status></ChangeInfo></GetChangeResponse>"
667            ),
668        }
669    }
670
671    fn list_record_sets(&self, query: &[(String, String)]) -> Response {
672        let field = |name: &str| {
673            query
674                .iter()
675                .find(|(key, _)| key == name)
676                .map(|(_, value)| value.clone())
677        };
678        let start = field("name").map(|name| (normalize(&name), field("type").unwrap_or_default()));
679
680        let listed: Vec<(&Key, &RecordSet)> = self
681            .sets
682            .iter()
683            .filter(|(_, set)| set.visible)
684            .filter(|(key, _)| match &start {
685                None => true,
686                Some(start) => *key >= start,
687            })
688            .collect();
689
690        let page = &listed[..listed.len().min(self.page_size)];
691        let mut body = String::from("<ListResourceRecordSetsResponse><ResourceRecordSets>");
692        for ((name, rtype), set) in page {
693            body.push_str(&format!(
694                "<ResourceRecordSet><Name>{name}.</Name><Type>{rtype}</Type><TTL>{}</TTL><ResourceRecords>",
695                set.ttl
696            ));
697            for value in &set.values {
698                body.push_str(&format!(
699                    "<ResourceRecord><Value>{}</Value></ResourceRecord>",
700                    escape(value)
701                ));
702            }
703            body.push_str("</ResourceRecords></ResourceRecordSet>");
704        }
705        body.push_str("</ResourceRecordSets>");
706        match listed.get(self.page_size) {
707            Some(((name, rtype), _)) => body.push_str(&format!(
708                "<IsTruncated>true</IsTruncated><NextRecordName>{name}.</NextRecordName>\
709                 <NextRecordType>{rtype}</NextRecordType>"
710            )),
711            None => body.push_str("<IsTruncated>false</IsTruncated>"),
712        }
713        body.push_str("<MaxItems>100</MaxItems></ListResourceRecordSetsResponse>");
714        Response { status: 200, body }
715    }
716}
717
718/// A [`Route53Dns`] backed by `aws`, wound down to test timings.
719///
720/// Retry backoff and the `GetChange` poll interval are milliseconds rather
721/// than the production hundreds of them: a test that drives a throttled call
722/// or a `PENDING` change through to its answer waits on the number of calls
723/// it takes, not on a clock.
724#[must_use]
725pub fn route53_provider(zone: &str, aws: &Arc<FakeAws>) -> Route53Dns {
726    Route53Dns::with_transport(zone, aws.hosted_zone_id(), Box::new(Arc::clone(aws)))
727        .with_retry_policy(RetryPolicy {
728            max_attempts: 5,
729            base_delay: Duration::from_millis(1),
730        })
731        .with_poll_interval(Duration::from_millis(1))
732        .with_propagation_timeout(Duration::from_secs(10))
733}
734
735// ---------------------------------------------------------------------------
736// The wire
737// ---------------------------------------------------------------------------
738
739/// One `<Change>` out of a `ChangeResourceRecordSets` body.
740struct WireChange {
741    action: String,
742    name: String,
743    rtype: String,
744    ttl: u32,
745    values: Vec<String>,
746}
747
748/// A record set name as the zone keys it: lowercased, no trailing dot.
749fn normalize(name: &str) -> String {
750    name.trim_end_matches('.').to_ascii_lowercase()
751}
752
753fn error_body(code: &str, message: &str) -> String {
754    format!(
755        "<ErrorResponse><Error><Type>Sender</Type><Code>{code}</Code>\
756         <Message>{}</Message></Error><RequestId>fake</RequestId></ErrorResponse>",
757        escape(message)
758    )
759}
760
761fn escape(value: &str) -> String {
762    value
763        .replace('&', "&amp;")
764        .replace('<', "&lt;")
765        .replace('>', "&gt;")
766}
767
768fn unescape(value: &str) -> String {
769    value
770        .replace("&lt;", "<")
771        .replace("&gt;", ">")
772        .replace("&quot;", "\"")
773        .replace("&apos;", "'")
774        .replace("&amp;", "&")
775}
776
777/// Text of the first `<tag>` at or after `from`, and the offset past it.
778fn tag(xml: &str, from: usize, name: &str) -> Option<(String, usize)> {
779    let open = format!("<{name}>");
780    let close = format!("</{name}>");
781    let start = xml[from..].find(&open)? + from + open.len();
782    let end = xml[start..].find(&close)? + start;
783    Some((unescape(&xml[start..end]), end + close.len()))
784}
785
786fn parse_changes(body: &str) -> Result<Vec<WireChange>, String> {
787    let mut changes = Vec::new();
788    let mut pos = 0;
789    while let Some(offset) = body[pos..].find("<Change>") {
790        let start = pos + offset;
791        let end = body[start..]
792            .find("</Change>")
793            .ok_or_else(|| "unterminated Change".to_string())?
794            + start;
795        let block = &body[start..end];
796        let (action, _) =
797            tag(block, 0, "Action").ok_or_else(|| "Change without Action".to_string())?;
798        let (name, _) = tag(block, 0, "Name").ok_or_else(|| "Change without Name".to_string())?;
799        let (rtype, _) = tag(block, 0, "Type").ok_or_else(|| "Change without Type".to_string())?;
800        let ttl = tag(block, 0, "TTL")
801            .and_then(|(value, _)| value.parse().ok())
802            .unwrap_or(300);
803        let mut values = Vec::new();
804        let mut vpos = 0;
805        while let Some((value, next)) = tag(block, vpos, "Value") {
806            values.push(value);
807            vpos = next;
808        }
809        changes.push(WireChange {
810            action: action.to_ascii_uppercase(),
811            name,
812            rtype: rtype.to_ascii_uppercase(),
813            ttl,
814            values,
815        });
816        pos = end + "</Change>".len();
817    }
818    if changes.is_empty() {
819        return Err("change batch with no changes".to_string());
820    }
821    Ok(changes)
822}