Skip to main content

microvms_app/control/
transport.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The control plane's rest-json calls, and the seam a transport implements.
3//!
4//! # Why this is hand-rolled
5//!
6//! `lambda-microvms` has no `aws-sdk-rust` crate. The alternatives were vendoring
7//! smithy-rs codegen — Gradle, JDK 17, Kotlin, and a project that declares its external
8//! interfaces unstable — or signing 24 rest-json operations by hand with the first-party
9//! low-level crates, which is the flow the AWS SDK maintainers point at for exactly this
10//! case. rest-json is also the easiest protocol to hand-roll: plain JSON bodies,
11//! URI-bound operations, standard headers, no XML and no query-protocol encoding.
12//!
13//! # The seam, and what the fake is allowed to know
14//!
15//! [`Transport`] is a trait with one method. The real implementation signs and sends;
16//! the test implementation is a **contract recorder** that asserts on the emitted
17//! request in the shape the service model declares.
18//!
19//! The rule the recorder follows is the lesson from the `buildState` bug: a fake that
20//! shares the client's assumptions hides exactly the class of defect the fake exists to
21//! catch. So the recorder never asserts on a value the client computed, and every
22//! response it hands back is **literal JSON in the model's spelling** — see
23//! [`crate::control::ops`] for why that is not a round trip through this crate's own
24//! serializer.
25//!
26//! # Local validation is not optional
27//!
28//! botocore's `VALIDATED_METADATA_ATTRS` is `{required, min, document, union}`, so
29//! `max`, `pattern`, and `enum` violations are serialized, sent, and answered with a
30//! `ValidationException` — confirmed empirically for `max` (runHookPayload 4097,
31//! maximumDurationInSeconds 28801, ImageName 65 chars, clientToken 129 chars), for
32//! `pattern`, and for `enum`. The same holds here with more force: **nothing** in this
33//! transport reads the service model at runtime, so every constraint is checked in the
34//! caller before a request is built or it is not checked at all.
35
36use crate::error::{Error, ErrorKind};
37use crate::region::Region;
38
39/// The pinned API version, which is also the first path segment of every operation.
40///
41/// Read from [`crate::constants::MODEL_API_VERSION`] rather than written again, so the
42/// drift gate's version check and the URLs cannot disagree: a client signing
43/// `/2025-09-09/...` while claiming to implement a different model is a client whose
44/// constraint checks were verified against the wrong thing.
45const API_PATH_VERSION: &str = crate::constants::MODEL_API_VERSION;
46
47/// The SigV4 signing name, from the model's `signingName`.
48///
49/// `lambda`, not `lambda-microvms`. The `endpointPrefix` is also `lambda`, which is why
50/// an endpoint resolves for every AWS region and a client constructs happily for a region
51/// that does not carry MicroVMs (TRAP-6, `crate::region`).
52pub const SIGNING_NAME: &str = "lambda";
53
54/// One control-plane call: method, path, optional JSON body.
55///
56/// A struct rather than a long parameter list because the recorder asserts on it as a
57/// whole, and because `method` and `path` next to each other is how a reader checks a
58/// path against the model's `http` trait.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct Call {
61    /// The operation name, for diagnostics and for the recorder's ledger.
62    ///
63    /// Carried rather than derived from the path: the recorder's TRAP-11 assertion is
64    /// "an exec lifecycle emits no call named `CreateMicrovmShellAuthToken`", and
65    /// matching that on a path is a substring test that a renamed route would slip past.
66    pub operation: &'static str,
67    /// `GET`, `POST`, `PATCH`, or `DELETE` — the four the model's `http` traits use for the
68    /// operations this client implements.
69    pub method: Method,
70    /// The path **after** the host, with the API version and every URI parameter already
71    /// percent-encoded. Includes the query string when the operation has one.
72    pub path: String,
73    /// The rest-json body, or `None` for an operation the model gives no body members.
74    pub body: Option<Vec<u8>>,
75}
76
77/// The HTTP methods the model's operations use.
78///
79/// An enum so a call site cannot pass `"post"` and have the canonical request silently
80/// disagree with the wire method.
81///
82/// # `Patch` is one operation's, and only one
83///
84/// The model uses `PATCH` for exactly `UpdateMicrovmImageVersion` and `PUT` for exactly
85/// `UpdateMicrovmImage`. `Put` is deliberately absent: this client does not implement that
86/// operation, and a variant nothing constructs is a variant that suggests it does.
87/// `PATCH` is not interchangeable with `POST` here — a `POST` to the version path is a route
88/// the service does not declare, and the answer is a 404 that reads as a missing version
89/// rather than as a wrong method.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum Method {
92    Get,
93    Post,
94    Patch,
95    Delete,
96}
97
98impl Method {
99    pub fn as_str(self) -> &'static str {
100        match self {
101            Method::Get => "GET",
102            Method::Post => "POST",
103            Method::Patch => "PATCH",
104            Method::Delete => "DELETE",
105        }
106    }
107}
108
109impl Call {
110    /// A `GET` with no body.
111    pub fn get(operation: &'static str, path: impl Into<String>) -> Self {
112        Self {
113            operation,
114            method: Method::Get,
115            path: path.into(),
116            body: None,
117        }
118    }
119
120    /// A `POST` carrying a serialized rest-json body.
121    pub fn post_json<T: serde::Serialize>(
122        operation: &'static str,
123        path: impl Into<String>,
124        body: &T,
125    ) -> Result<Self, Error> {
126        let body = serde_json::to_vec(body).map_err(|error| {
127            Error::new(
128                ErrorKind::Unexpected,
129                format!("could not serialize the {operation} request body: {error}"),
130            )
131        })?;
132        Ok(Self {
133            operation,
134            method: Method::Post,
135            path: path.into(),
136            body: Some(body),
137        })
138    }
139
140    /// A `PATCH` carrying a serialized rest-json body.
141    ///
142    /// The same shape as [`Call::post_json`] and written beside it rather than folded into a
143    /// method-taking constructor, for the reason [`Method`]'s docs give: the method is part of
144    /// the route the model declares, and a `with_method(Method::Patch)` builder is one a call
145    /// site can forget to call. `UpdateMicrovmImageVersion` is the only operation that reaches
146    /// this.
147    pub fn patch_json<T: serde::Serialize>(
148        operation: &'static str,
149        path: impl Into<String>,
150        body: &T,
151    ) -> Result<Self, Error> {
152        let body = serde_json::to_vec(body).map_err(|error| {
153            Error::new(
154                ErrorKind::Unexpected,
155                format!("could not serialize the {operation} request body: {error}"),
156            )
157        })?;
158        Ok(Self {
159            operation,
160            method: Method::Patch,
161            path: path.into(),
162            body: Some(body),
163        })
164    }
165
166    /// A `POST` with no body members. `SuspendMicrovm` and `ResumeMicrovm` are both this
167    /// shape: their only member is the URI-located identifier.
168    ///
169    /// The body is `Some(b"{}")` rather than `None`, because an empty POST and a POST of
170    /// `{}` are different canonical requests and rest-json services answer the former
171    /// inconsistently.
172    pub fn post_empty(operation: &'static str, path: impl Into<String>) -> Self {
173        Self {
174            operation,
175            method: Method::Post,
176            path: path.into(),
177            body: Some(b"{}".to_vec()),
178        }
179    }
180
181    /// A `DELETE` with no body.
182    pub fn delete(operation: &'static str, path: impl Into<String>) -> Self {
183        Self {
184            operation,
185            method: Method::Delete,
186            path: path.into(),
187            body: None,
188        }
189    }
190
191    /// The body as bytes, empty when there is none — which is what gets signed.
192    pub fn body_bytes(&self) -> &[u8] {
193        self.body.as_deref().unwrap_or(&[])
194    }
195}
196
197/// What the control plane answered: a status, a raw body, and the error type the service
198/// named, if it named one.
199///
200/// Raw bytes rather than a deserialized value because the error path has to read a body
201/// the success types cannot hold, and because TRAP-6's null `message` field is only
202/// visible to something that did not already require a `String` there.
203#[derive(Clone, Debug)]
204pub struct Reply {
205    pub status: u16,
206    pub body: Vec<u8>,
207    /// The `x-amzn-ErrorType` header's error name, cut down by [`error_type_name`], or
208    /// `None` when the response carried no such header. [`classify_failure`] reads it
209    /// before the status, because the service has sent a throttle as HTTP 400.
210    pub error_type: Option<String>,
211}
212
213impl Reply {
214    /// Deserializes the body as `T`, or reports the failure with the body attached.
215    ///
216    /// The body goes in the message because a rest-json parse failure with the text
217    /// withheld is unactionable: the reader cannot tell a renamed member from a truncated
218    /// response. Truncated to 512 bytes, since a list response can be large and the first
219    /// 512 characters always contain the member that failed.
220    pub fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
221        serde_json::from_slice(&self.body).map_err(|error| {
222            let text = String::from_utf8_lossy(&self.body);
223            let shown: String = text.chars().take(512).collect();
224            Error::new(
225                ErrorKind::Platform,
226                format!(
227                    "could not read the {operation} response ({error}). The service model for \
228                     {API_PATH_VERSION} is what this client's shapes are transcribed from, so a \
229                     member that has moved is a drift-gate failure rather than a transport bug. \
230                     Body: {shown}"
231                ),
232            )
233            .with_source(error)
234        })
235    }
236
237    /// Whether the status is a success. rest-json operations here answer 200 or 201.
238    fn is_success(&self) -> bool {
239        (200..300).contains(&self.status)
240    }
241}
242
243/// The one seam every operation goes through.
244///
245/// One method, so a fake is a few lines and there is no partially-overridden transport
246/// whose behaviour is half real. `&self` rather than `&mut self` so the recorder can be
247/// shared behind an `Arc` while a lifecycle runs.
248pub trait Transport: Send + Sync {
249    /// Performs `call`, returning whatever the far side answered.
250    ///
251    /// A transport-level failure (no status at all) is an `Err`; a 4xx or 5xx is an
252    /// `Ok(Reply)` carrying the status, because classifying a status is
253    /// [`classify_failure`]'s job and a transport that classified would be a second
254    /// place the taxonomy lives.
255    fn send(
256        &self,
257        call: Call,
258    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>;
259
260    /// Resolves the credentials the next [`Transport::send`] would sign with, sending nothing
261    /// to the service (BIND-15's credentials check).
262    ///
263    /// Resolving may reach an SSO, credential-process, or instance-metadata source, none of
264    /// which bills. The default answers `Ok`: a transport that does not sign (every scripted
265    /// one) has nothing to resolve.
266    fn resolve_credentials(
267        &self,
268    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
269        Box::pin(async { Ok(()) })
270    }
271}
272
273/// Sends `call` through `transport`, retrying the retryable statuses, and turns a failure
274/// status into a classified [`Error`].
275///
276/// # Which failures retry
277///
278/// 429 (`ThrottlingException`), 500 (`InternalServerException`), and any other 5xx, plus
279/// a transport error that produced no status. AWS standard-mode semantics, matched by
280/// hand because the smithy retry pieces are coupled to an orchestrator this client does
281/// not have.
282///
283/// # Why retrying the mutating operations is safe
284///
285/// Every mutating operation this client sends carries a `clientToken`, so a retry is
286/// idempotent by the service's own contract — which is the same property that makes the
287/// token dangerous to derive (TRAP-1). `SuspendMicrovm`/`ResumeMicrovm`/`TerminateMicrovm`
288/// take no token and are retried anyway: they are state transitions whose second
289/// application is a no-op or a `ConflictException`, and a conflict is not retried.
290pub async fn send_with_retry(transport: &dyn Transport, call: Call) -> Result<Reply, Error> {
291    use backon::{ExponentialBuilder, Retryable};
292
293    let operation = call.operation;
294    let attempt = || {
295        let call = call.clone();
296        async move {
297            let reply = transport.send(call).await?;
298            if reply.is_success() {
299                return Ok(reply);
300            }
301            Err(classify_failure(operation, &reply))
302        }
303    };
304
305    attempt
306        .retry(
307            ExponentialBuilder::default()
308                .with_jitter()
309                .with_min_delay(std::time::Duration::from_millis(200))
310                .with_max_delay(std::time::Duration::from_secs(20))
311                .with_max_times(5),
312        )
313        .when(Error::retryable)
314        .await
315}
316
317/// [`send_with_retry`], answering `Ok` for the statuses in `accept` as well as for a
318/// success.
319///
320/// For a call whose failure status is an answer rather than an error: `GetMicrovmImage`'s 404
321/// is "there is no image under this name", which `ensure_image` reads as a state
322/// ([`crate::control::ensure`]). Retries are unchanged — a throttle or a 5xx is still
323/// retried — and every other status is still classified by [`classify_failure`].
324pub async fn send_accepting(
325    transport: &dyn Transport,
326    call: Call,
327    accept: &[u16],
328) -> Result<Reply, Error> {
329    use backon::{ExponentialBuilder, Retryable};
330
331    let operation = call.operation;
332    let attempt = || {
333        let call = call.clone();
334        async move {
335            let reply = transport.send(call).await?;
336            if reply.is_success() || accept.contains(&reply.status) {
337                return Ok(reply);
338            }
339            Err(classify_failure(operation, &reply))
340        }
341    };
342
343    attempt
344        .retry(
345            ExponentialBuilder::default()
346                .with_jitter()
347                .with_min_delay(std::time::Duration::from_millis(200))
348                .with_max_delay(std::time::Duration::from_secs(20))
349                .with_max_times(5),
350        )
351        .when(Error::retryable)
352        .await
353}
354
355/// Turns a failure status and body into an [`Error`] of the right kind.
356///
357/// # The statuses are the control plane's, not the daemon's
358///
359/// [`crate::error::WireKind`] is the *daemon's* status discipline and deliberately has no
360/// generic 4xx fallback, because the daemon's 400-versus-404 distinction is load-bearing.
361/// None of that applies here: these seven statuses come from the modeled exception shapes
362/// (`ValidationException` 400, `ServiceQuotaExceededException` 402,
363/// `AccessDeniedException` 403, `ResourceNotFoundException` 404, `ConflictException` 409,
364/// `ThrottlingException` 429, `InternalServerException` 500). So a control-plane failure
365/// carries an [`ErrorKind`] and **no** `WireKind` — which is what `error.rs` already
366/// documents: "`None` for every local reject and every control-plane failure — those never
367/// reached the in-VM daemon".
368///
369/// # 403 and the null message
370///
371/// A 403 whose `message` is null is the unsupported-region signature (TRAP-6). The
372/// message says so, because the alternative — forwarding a null as "AccessDenied: " — is
373/// precisely what sends someone to audit an IAM policy that is fine.
374pub fn classify_failure(operation: &str, reply: &Reply) -> Error {
375    let parsed: crate::control::ops::ServiceErrorWire =
376        serde_json::from_slice(&reply.body).unwrap_or_default();
377    let detail = parsed.message.as_deref().unwrap_or("").trim().to_string();
378
379    let (kind, explanation) = match reply.status {
380        // The error type outranks the status. A throttled `ListMicrovms` answered HTTP 400
381        // with `x-amzn-ErrorType: ThrottlingException`, where the model says 429
382        // (docs/PLATFORM.md, measured 2026-10-02), and read by status alone it was a
383        // validation refusal that nothing retried.
384        status if reply.error_type.as_deref() == Some("ThrottlingException") => (
385            ErrorKind::Retryable,
386            format!(
387                "ThrottlingException, named by the x-amzn-ErrorType header on an HTTP {status} \
388                 — retry the identical request."
389            ),
390        ),
391        400 => (
392            ErrorKind::Platform,
393            "ValidationException — the service refused a value. Every constraint the pinned \
394             service model states on a member this client sends is checked locally before the \
395             call — min as well as max, pattern, and enum, because nothing else validates a \
396             request here: this client signs with aws-sigv4 and sends with reqwest, so botocore's \
397             VALIDATED_METADATA_ATTRS never applies to it. So reaching this status means a \
398             constraint this client does not yet check, and closing it is a guard in \
399             crates/microvms-app/src/control plus a line in tools/check-model-drift.py."
400                .to_string(),
401        ),
402        402 => (
403            ErrorKind::Platform,
404            "ServiceQuotaExceededException — an account limit, not a request defect.".to_string(),
405        ),
406        403 if detail.is_empty() => (
407            ErrorKind::Credentials,
408            "AccessDeniedException with an empty or null message field. Measured 2026-08-07: this \
409             is the signature of a region that does not carry MicroVMs, and it is \
410             indistinguishable from a genuine IAM denial except that a real denial names the \
411             principal and the action (docs/PLATFORM.md, 'Calling an unpriced region returns \
412             AccessDeniedException with a null message'). Check the region before the policy."
413                .to_string(),
414        ),
415        403 => (
416            ErrorKind::Credentials,
417            "AccessDeniedException, with a message — so this is a genuine denial rather than the \
418             null-message region trap."
419                .to_string(),
420        ),
421        404 => (
422            ErrorKind::Platform,
423            "ResourceNotFoundException — the image or MicroVM does not exist.".to_string(),
424        ),
425        409 => (
426            ErrorKind::Platform,
427            "ConflictException — the resource is in a state that forbids this call. An image in \
428             CREATING refuses deletion, which is why teardown retries."
429                .to_string(),
430        ),
431        429 => (
432            ErrorKind::Retryable,
433            "ThrottlingException — retry the identical request.".to_string(),
434        ),
435        status if status >= 500 => (
436            ErrorKind::Retryable,
437            "InternalServerException — a service fault, retry the identical request.".to_string(),
438        ),
439        _ => (
440            ErrorKind::Platform,
441            "an unmodeled status: the service model declares 400, 402, 403, 404, 409, 429, and \
442             500 for these operations."
443                .to_string(),
444        ),
445    };
446
447    let said = if detail.is_empty() {
448        "the service sent no message".to_string()
449    } else {
450        format!("the service said {detail:?}")
451    };
452    Error::new(
453        kind,
454        format!(
455            "{operation} failed with HTTP {}: {said}. {explanation}",
456            reply.status
457        ),
458    )
459}
460
461/// The error name in an `x-amzn-ErrorType` header value: what precedes the first `:` and
462/// follows the first `#`.
463///
464/// That is the rest-json protocol's rule, and both parts occur here: the service's throttle
465/// arrived as `ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/`,
466/// and other services send a `namespace#Name` form.
467pub fn error_type_name(header: &str) -> &str {
468    let name = header.split(':').next().unwrap_or(header);
469    name.split_once('#').map_or(name, |(_, after)| after).trim()
470}
471
472/// `https://lambda.<region>.amazonaws.com`, from the model's `endpointPrefix`.
473///
474/// The prefix is `lambda` rather than `lambda-microvms`, which is why this resolves for
475/// every AWS region including the ones that answer the null-message denial.
476pub fn endpoint_for(region: &Region) -> String {
477    format!("https://{SIGNING_NAME}.{}.amazonaws.com", region.as_str())
478}
479
480/// The operation paths, one function per `http.requestUri` in the model.
481///
482/// Here rather than inlined at each call site so a path is written once and can be read
483/// against the model in one place. Every URI parameter is percent-encoded, because an
484/// image identifier may be an ARN — which contains `:` and `/` — and an unencoded ARN in a
485/// path segment is a different resource.
486pub mod paths {
487    use super::API_PATH_VERSION;
488
489    /// Percent-encodes a URI path segment.
490    ///
491    /// Everything outside RFC 3986's unreserved set is escaped. **`/` is encoded**,
492    /// which is the whole point: an image ARN's slashes would otherwise split into
493    /// extra path segments and address something else. `:` is encoded too — legal
494    /// unencoded in a path per the RFC, but AWS's own SDKs encode it in labels and
495    /// the signature must match whatever is sent.
496    pub fn encode_segment(segment: &str) -> String {
497        use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
498        const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
499            .remove(b'-')
500            .remove(b'_')
501            .remove(b'.')
502            .remove(b'~');
503        utf8_percent_encode(segment, ESCAPED).to_string()
504    }
505
506    /// `POST /2025-09-09/microvm-images`
507    pub fn microvm_images() -> String {
508        format!("/{API_PATH_VERSION}/microvm-images")
509    }
510
511    /// `GET /2025-09-09/microvm-images`, with the listing's query members.
512    ///
513    /// `nameFilter` is the model's server-side **substring** filter — it narrows the
514    /// listing, it does not answer "the image named X", so an exact-match comparison
515    /// still happens in the caller. `nextToken` is the pagination cursor and is opaque,
516    /// which is why both values go through [`encode_segment`]: an opaque token may carry
517    /// `+` or `=`, and an unencoded one desynchronises the SigV4 canonical query from
518    /// the query actually sent.
519    pub fn microvm_images_list(name_filter: Option<&str>, next_token: Option<&str>) -> String {
520        let mut query: Vec<String> = Vec::new();
521        if let Some(filter) = name_filter {
522            query.push(format!("nameFilter={}", encode_segment(filter)));
523        }
524        if let Some(token) = next_token {
525            query.push(format!("nextToken={}", encode_segment(token)));
526        }
527        if query.is_empty() {
528            return microvm_images();
529        }
530        format!("{}?{}", microvm_images(), query.join("&"))
531    }
532
533    /// `GET|DELETE /2025-09-09/microvm-images/{imageIdentifier}`
534    pub fn microvm_image(image: &str) -> String {
535        format!(
536            "/{API_PATH_VERSION}/microvm-images/{}",
537            encode_segment(image)
538        )
539    }
540
541    /// One `nextToken=` query member, appended to `path`, or `path` unchanged.
542    ///
543    /// Shared by the three listings that take nothing else, so the encoding decision is
544    /// made once. The value goes through [`encode_segment`] for the reason
545    /// [`microvm_images_list`] states: a pagination token is **opaque**, so it may carry
546    /// `+`, `/`, or `=`, and an unencoded one desynchronises the SigV4 canonical query
547    /// from the query actually sent — a rejection that reads like bad credentials rather
548    /// than like a malformed URL.
549    fn with_next_token(path: String, next_token: Option<&str>) -> String {
550        match next_token {
551            Some(token) => format!("{path}?nextToken={}", encode_segment(token)),
552            None => path,
553        }
554    }
555
556    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions`, with the pagination
557    /// cursor when there is one.
558    ///
559    /// `maxResults` is not sent: the model caps it at 50 and the service's own default is
560    /// what a caller reading every page wants, so naming a page size would only be a
561    /// second number to keep right. [`image_versions_paged`] is the one caller that does
562    /// name one, and says why.
563    pub fn image_versions(image: &str, next_token: Option<&str>) -> String {
564        image_versions_paged(image, next_token, None)
565    }
566
567    /// [`image_versions`] with an explicit `maxResults`, which **no production path sends**.
568    ///
569    /// # Why a page size exists at all when nothing ships one
570    ///
571    /// Because without it the cursor encoding is unfalsifiable against the real service,
572    /// and that encoding is the one place in this module where being wrong reads as a
573    /// *credentials* failure rather than a URL one. A real `nextToken` is opaque base64
574    /// carrying `+`, `/`, and `=`; an unencoded one desynchronises the SigV4 canonical
575    /// query and the service answers 403. Every test that could catch it went through an
576    /// injected transport, so the fake minted the token and the signer never saw it.
577    ///
578    /// Forcing a real cursor needs a listing with more items than one page, and the
579    /// service's default page is larger than any image's version count here — so the only
580    /// way to make the real service mint a real cursor for a resource that already exists
581    /// is to ask for a smaller page. That is what this is for, and
582    /// `crates/microvms-core/tests/live_pagination.rs` is its caller.
583    ///
584    /// # Why it is not simply a parameter on [`image_versions`]
585    ///
586    /// A page size on the production path is a second number to keep right, and a wrong
587    /// one is invisible: reading every page with `maxResults=1` is correct and slow, which
588    /// is the kind of defect that survives. Keeping it to a separately named function means
589    /// the production call sites cannot acquire one by accident, and a reader of
590    /// [`image_versions`] sees no page size to wonder about.
591    ///
592    /// `max_results` is clamped to the model's `1..=50`, because a value outside it is a
593    /// `ValidationException` and a caller of this function is trying to observe pagination
594    /// rather than to discover the bound.
595    pub fn image_versions_paged(
596        image: &str,
597        next_token: Option<&str>,
598        max_results: Option<u32>,
599    ) -> String {
600        let mut query: Vec<String> = Vec::new();
601        if let Some(size) = max_results {
602            query.push(format!("maxResults={}", size.clamp(1, 50)));
603        }
604        if let Some(token) = next_token {
605            query.push(format!("nextToken={}", encode_segment(token)));
606        }
607        let path = format!("{}/versions", microvm_image(image));
608        if query.is_empty() {
609            return path;
610        }
611        format!("{path}?{}", query.join("&"))
612    }
613
614    /// `GET|PATCH|DELETE /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}`
615    ///
616    /// One path for three operations, because the model gives them one `requestUri` and the
617    /// method is what distinguishes them: `GetMicrovmImageVersion` reads it,
618    /// `UpdateMicrovmImageVersion` patches it, `DeleteMicrovmImageVersion` deletes it. The
619    /// method lives on the [`Call`] rather than in three path functions, so a reader checking
620    /// this against `service-2.json` compares one string.
621    ///
622    /// Built from [`microvm_image`] rather than from [`image_versions`], because that one
623    /// now takes a cursor and none of the three may carry a query member.
624    pub fn image_version(image: &str, version: &str) -> String {
625        format!(
626            "{}/versions/{}",
627            microvm_image(image),
628            encode_segment(version)
629        )
630    }
631
632    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds/{buildId}`
633    ///
634    /// The list path plus one segment. `buildId` is percent-encoded like every other URI
635    /// parameter even though a real one is a UUID: the model types it `NonBlankString`, so it
636    /// is not the encoder's business to assume the shape of a value the service mints.
637    pub fn image_build(image: &str, version: &str, build_id: &str) -> String {
638        format!(
639            "{}/builds/{}",
640            image_version(image, version),
641            encode_segment(build_id)
642        )
643    }
644
645    /// `GET /2025-09-09/managed-microvm-images`, with the pagination cursor when there is one.
646    ///
647    /// A **different route** from [`microvm_images`], not a filter on it: the managed bases
648    /// live under `managed-microvm-images` and their summaries carry a different shape
649    /// (`ManagedMicrovmImageSummary`, three members). Reading the account's own image listing
650    /// would never return one.
651    pub fn managed_microvm_images(next_token: Option<&str>) -> String {
652        with_next_token(
653            format!("/{API_PATH_VERSION}/managed-microvm-images"),
654            next_token,
655        )
656    }
657
658    /// `GET /2025-09-09/managed-microvm-images/{imageIdentifier}/versions`, with the cursor.
659    ///
660    /// `imageIdentifier` here is the managed base's **full ARN**, not its name: measured
661    /// 2026-08-16, `al2023-1` alone answers `ValidationException: Invalid ARN format:
662    /// al2023-1`. That is why [`crate::control::BaseImage::arn`] is what the caller passes —
663    /// and why the wrapper refuses a non-ARN locally, since the rejection names the value
664    /// without saying which member wanted an ARN.
665    pub fn managed_image_versions(image: &str, next_token: Option<&str>) -> String {
666        with_next_token(
667            format!(
668                "/{API_PATH_VERSION}/managed-microvm-images/{}/versions",
669                encode_segment(image)
670            ),
671            next_token,
672        )
673    }
674
675    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds`,
676    /// with the pagination cursor when there is one.
677    pub fn image_builds(image: &str, version: &str, next_token: Option<&str>) -> String {
678        with_next_token(
679            format!("{}/builds", image_version(image, version)),
680            next_token,
681        )
682    }
683
684    /// `POST /2025-09-09/microvms`
685    ///
686    /// The bare collection path, which is what `RunMicrovm` posts to. `ListMicrovms` reads
687    /// the same route and lives in [`microvms_list`] rather than here, so a launch cannot
688    /// accidentally carry a `nextToken` — the same split [`microvm_images`] and
689    /// [`microvm_images_list`] already make.
690    pub fn microvms() -> String {
691        format!("/{API_PATH_VERSION}/microvms")
692    }
693
694    /// `GET /2025-09-09/microvms`, with the pagination cursor when there is one.
695    pub fn microvms_list(next_token: Option<&str>) -> String {
696        with_next_token(microvms(), next_token)
697    }
698
699    /// [`microvms_list`] with the model's `imageIdentifier` and `imageVersion` query filters.
700    ///
701    /// Keys are emitted in sorted order, which is also SigV4's canonical order.
702    pub fn microvms_list_matching(
703        image_identifier: Option<&str>,
704        image_version: Option<&str>,
705        next_token: Option<&str>,
706    ) -> String {
707        let query: Vec<String> = [
708            ("imageIdentifier", image_identifier),
709            ("imageVersion", image_version),
710            ("nextToken", next_token),
711        ]
712        .into_iter()
713        .filter_map(|(key, value)| value.map(|value| format!("{key}={}", encode_segment(value))))
714        .collect();
715        if query.is_empty() {
716            return microvms();
717        }
718        format!("{}?{}", microvms(), query.join("&"))
719    }
720
721    /// `GET|DELETE /2025-09-09/microvms/{microvmIdentifier}`
722    pub fn microvm(id: &str) -> String {
723        format!("/{API_PATH_VERSION}/microvms/{}", encode_segment(id))
724    }
725
726    /// `POST /2025-09-09/microvms/{microvmIdentifier}/suspend`
727    pub fn suspend(id: &str) -> String {
728        format!("{}/suspend", microvm(id))
729    }
730
731    /// `POST /2025-09-09/microvms/{microvmIdentifier}/resume`
732    pub fn resume(id: &str) -> String {
733        format!("{}/resume", microvm(id))
734    }
735
736    /// `POST /2025-09-09/microvms/{microvmIdentifier}/auth-token`
737    pub fn auth_token(id: &str) -> String {
738        format!("{}/auth-token", microvm(id))
739    }
740
741    /// `POST /2025-09-09/microvms/{microvmIdentifier}/shell-auth-token`
742    ///
743    /// The shell token's route, its own path rather than a parameter on
744    /// [`auth_token`]: the two operations take different bodies (`allowedPorts` exists
745    /// only on the ordinary one — the shell is not a port), and the exec path must never
746    /// be one boolean away from minting a shell credential (TRAP-11).
747    pub fn shell_auth_token(id: &str) -> String {
748        format!("{}/shell-auth-token", microvm(id))
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    /// The endpoint, from the model's `endpointPrefix`. `lambda` and not
757    /// `lambda-microvms` — which is the fact that makes TRAP-6 possible, since this
758    /// resolves for every AWS region.
759    #[test]
760    fn the_endpoint_uses_the_models_lambda_prefix_for_every_region() {
761        assert_eq!(
762            endpoint_for(&Region::UsEast1),
763            "https://lambda.us-east-1.amazonaws.com"
764        );
765        assert_eq!(
766            endpoint_for(&Region::ApNortheast1),
767            "https://lambda.ap-northeast-1.amazonaws.com"
768        );
769        // Including one that does not carry MicroVMs, which is the point.
770        assert_eq!(
771            endpoint_for(&Region::unlisted("eu-central-1")),
772            "https://lambda.eu-central-1.amazonaws.com"
773        );
774    }
775
776    /// Every path against the model's `http.requestUri`, as literals. Transcribed from
777    /// `service-2.json` rather than built from a template, because a template shared with
778    /// the code under test would agree with a wrong template.
779    #[test]
780    fn every_path_matches_the_models_request_uri() {
781        assert_eq!(paths::microvm_images(), "/2025-09-09/microvm-images");
782        assert_eq!(
783            paths::microvm_image("img"),
784            "/2025-09-09/microvm-images/img"
785        );
786        assert_eq!(
787            paths::image_versions("img", None),
788            "/2025-09-09/microvm-images/img/versions"
789        );
790        assert_eq!(
791            paths::image_version("img", "1"),
792            "/2025-09-09/microvm-images/img/versions/1"
793        );
794        assert_eq!(
795            paths::image_builds("img", "1", None),
796            "/2025-09-09/microvm-images/img/versions/1/builds"
797        );
798        assert_eq!(
799            paths::image_build("img", "1", "build-abc"),
800            "/2025-09-09/microvm-images/img/versions/1/builds/build-abc"
801        );
802        assert_eq!(
803            paths::managed_microvm_images(None),
804            "/2025-09-09/managed-microvm-images"
805        );
806        assert_eq!(
807            paths::managed_image_versions(
808                "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
809                None
810            ),
811            "/2025-09-09/managed-microvm-images/\
812             arn%3Aaws%3Alambda%3Aus-east-1%3Aaws%3Amicrovm-image%3Aal2023-1/versions"
813        );
814        assert_eq!(paths::microvms(), "/2025-09-09/microvms");
815        assert_eq!(paths::microvms_list(None), "/2025-09-09/microvms");
816        assert_eq!(paths::microvm("mvm-1"), "/2025-09-09/microvms/mvm-1");
817        assert_eq!(
818            paths::suspend("mvm-1"),
819            "/2025-09-09/microvms/mvm-1/suspend"
820        );
821        assert_eq!(paths::resume("mvm-1"), "/2025-09-09/microvms/mvm-1/resume");
822        assert_eq!(
823            paths::auth_token("mvm-1"),
824            "/2025-09-09/microvms/mvm-1/auth-token"
825        );
826        assert_eq!(
827            paths::shell_auth_token("mvm-1"),
828            "/2025-09-09/microvms/mvm-1/shell-auth-token"
829        );
830    }
831
832    /// The listing path carries its query members in the model's spelling, percent-encoded,
833    /// and a bare listing is the plain collection path with no `?`.
834    ///
835    /// An opaque `nextToken` may carry `+`, `/`, or `=`; unencoded, those desynchronise the
836    /// SigV4 canonical query from the query actually sent, and the rejection reads like bad
837    /// credentials.
838    #[test]
839    fn the_image_listing_path_encodes_its_query_members() {
840        assert_eq!(
841            paths::microvm_images_list(None, None),
842            "/2025-09-09/microvm-images"
843        );
844        assert_eq!(
845            paths::microvm_images_list(Some("my-image"), None),
846            "/2025-09-09/microvm-images?nameFilter=my-image"
847        );
848        assert_eq!(
849            paths::microvm_images_list(Some("my-image"), Some("a+b/c=")),
850            "/2025-09-09/microvm-images?nameFilter=my-image&nextToken=a%2Bb%2Fc%3D"
851        );
852        assert_eq!(
853            paths::microvm_images_list(None, Some("token")),
854            "/2025-09-09/microvm-images?nextToken=token"
855        );
856    }
857
858    /// The other three paginated listings encode their cursor the same way, and emit no
859    /// `?` at all without one.
860    ///
861    /// Written as literals per listing rather than as a loop over a helper, because the
862    /// bug this closes is a path that forgets the cursor entirely — and a loop over the
863    /// helper would pass against three call sites that never pass one.
864    ///
865    /// **Falsification** — drop the [`paths::encode_segment`] call from
866    /// `with_next_token` and every `%2B`/`%2F`/`%3D` assertion below goes red, which is
867    /// the SigV4 canonical-query desynchronisation this encoder exists to prevent.
868    #[test]
869    fn the_other_paginated_listings_encode_their_cursor_too() {
870        assert_eq!(
871            paths::image_versions("img", Some("a+b/c=")),
872            "/2025-09-09/microvm-images/img/versions?nextToken=a%2Bb%2Fc%3D"
873        );
874        assert_eq!(
875            paths::image_builds("img", "1", Some("a+b/c=")),
876            "/2025-09-09/microvm-images/img/versions/1/builds?nextToken=a%2Bb%2Fc%3D"
877        );
878        assert_eq!(
879            paths::microvms_list(Some("a+b/c=")),
880            "/2025-09-09/microvms?nextToken=a%2Bb%2Fc%3D"
881        );
882
883        // No cursor means no query string, not an empty one: `?nextToken=` is a member
884        // present and blank, which is a different request from a member absent.
885        for path in [
886            paths::image_versions("img", None),
887            paths::image_builds("img", "1", None),
888            paths::microvms_list(None),
889        ] {
890            assert!(
891                !path.contains('?'),
892                "an absent cursor emits no query: {path}"
893            );
894        }
895    }
896
897    /// The version listing's explicit page size: ordered before the cursor, clamped to the
898    /// model's `1..=50`, and absent from the path when nothing asks for one.
899    ///
900    /// The clamp is asserted at both ends because a value outside the bound is a
901    /// `ValidationException`, and the only caller of this function
902    /// (`tests/live_pagination.rs`) is trying to *observe* a real cursor — a run that
903    /// failed on the page size instead would prove nothing about the encoding it came for.
904    ///
905    /// **Falsification** — remove the `.clamp(1, 50)` and the `0` and `999` cases go red
906    /// with `maxResults=0` and `maxResults=999`, both of which the service refuses.
907    #[test]
908    fn the_version_listings_explicit_page_size_is_ordered_and_clamped() {
909        assert_eq!(
910            paths::image_versions_paged("img", None, Some(1)),
911            "/2025-09-09/microvm-images/img/versions?maxResults=1"
912        );
913        // Both members, and `maxResults` first — the order the query is built in, which is
914        // asserted so a reordering is a visible change rather than a silent one.
915        assert_eq!(
916            paths::image_versions_paged("img", Some("a+b/c="), Some(2)),
917            "/2025-09-09/microvm-images/img/versions?maxResults=2&nextToken=a%2Bb%2Fc%3D"
918        );
919        assert_eq!(
920            paths::image_versions_paged("img", None, Some(0)),
921            "/2025-09-09/microvm-images/img/versions?maxResults=1",
922            "the model's min is 1; a 0 would be refused"
923        );
924        assert_eq!(
925            paths::image_versions_paged("img", None, Some(999)),
926            "/2025-09-09/microvm-images/img/versions?maxResults=50",
927            "the model's max is 50; a larger value would be refused"
928        );
929
930        // And the production spelling is unchanged by the new one existing: `image_versions`
931        // delegates here with no page size, so it must still emit exactly what it did.
932        assert_eq!(
933            paths::image_versions_paged("img", None, None),
934            paths::image_versions("img", None)
935        );
936        assert!(
937            !paths::image_versions("img", Some("t")).contains("maxResults"),
938            "no production path names a page size: {}",
939            paths::image_versions("img", Some("t"))
940        );
941    }
942
943    /// The **managed** routes are a different collection from the account's own, and their
944    /// cursor is encoded the same way.
945    ///
946    /// Asserted as its own case because the plausible mistake is treating the managed listing
947    /// as a filter on `microvm-images` — it is a separate `requestUri` in the model with a
948    /// different response shape, so a path built from [`paths::microvm_images`] would address
949    /// the account's images and answer 200 with the wrong items.
950    ///
951    /// **Falsification** — drop the `managed-` prefix from either function and the two
952    /// literals below go red while every request still succeeds against the real service,
953    /// which is exactly why these are literals transcribed from the model rather than a
954    /// shared template.
955    #[test]
956    fn the_managed_routes_are_their_own_collection_and_encode_their_cursor() {
957        assert!(
958            !paths::managed_microvm_images(None).contains('?'),
959            "an absent cursor emits no query member"
960        );
961        assert_eq!(
962            paths::managed_microvm_images(Some("a+b/c=")),
963            "/2025-09-09/managed-microvm-images?nextToken=a%2Bb%2Fc%3D"
964        );
965        assert_eq!(
966            paths::managed_image_versions("al2023-1", Some("a+b/c=")),
967            "/2025-09-09/managed-microvm-images/al2023-1/versions?nextToken=a%2Bb%2Fc%3D"
968        );
969
970        // Not a filter on the account's own listing: the two collections are different path
971        // segments, and reading one for the other answers 200 with the wrong items.
972        assert_ne!(
973            paths::managed_microvm_images(None),
974            paths::microvm_images_list(None, None)
975        );
976        assert!(
977            !paths::microvm_images().contains("managed"),
978            "the account's own collection must not acquire the managed prefix"
979        );
980    }
981
982    /// A `GET` of one build is the list path plus one encoded segment, and it carries no query
983    /// member.
984    ///
985    /// The `buildId` is percent-encoded even though a real one is a UUID: the model types it
986    /// `NonBlankString`, so the encoder does not get to assume the shape of a value the
987    /// service mints.
988    #[test]
989    fn one_builds_path_is_the_listing_plus_an_encoded_segment() {
990        let path = paths::image_build("img", "1", "4a4c5e30-811f-47fa-9893-260ea6a37a8f");
991        assert_eq!(
992            path,
993            "/2025-09-09/microvm-images/img/versions/1/builds/4a4c5e30-811f-47fa-9893-260ea6a37a8f"
994        );
995        assert!(!path.contains('?'), "{path}");
996        assert!(
997            path.starts_with(&paths::image_builds("img", "1", None)),
998            "the get path must extend the list path rather than be a second route: {path}"
999        );
1000        // And an identifier with a separator in it cannot split into extra segments.
1001        assert_eq!(
1002            paths::image_build("img", "1", "a/b"),
1003            "/2025-09-09/microvm-images/img/versions/1/builds/a%2Fb"
1004        );
1005    }
1006
1007    /// A `DELETE` of one version carries no query member, even though its sibling
1008    /// [`paths::image_versions`] now takes a cursor. A version path with `?nextToken=`
1009    /// appended addresses nothing.
1010    #[test]
1011    fn deleting_one_version_carries_no_pagination_cursor() {
1012        let path = paths::image_version("img", "1");
1013        assert_eq!(path, "/2025-09-09/microvm-images/img/versions/1");
1014        assert!(!path.contains('?'), "{path}");
1015        assert!(!path.contains("nextToken"), "{path}");
1016    }
1017
1018    /// A **real** image ARN in a URI parameter is percent-encoded, every colon included.
1019    /// `MicrovmImageIdentifier` is documented as "ARN or ID", so this is the ordinary case
1020    /// rather than an edge one.
1021    ///
1022    /// The literal is the shape the service actually returns, measured 2026-08-15 in
1023    /// us-east-1: `microvm-image:<name>` with a **colon**, which is also the only form the
1024    /// model's `TaggableResource` pattern admits. This test used to hold
1025    /// `microvm-image/img`, and the slash was not a harmless stand-in — `GetMicrovmImage`
1026    /// on that form answers `AccessDeniedException`, because IAM evaluates a malformed ARN
1027    /// as a resource with no matching policy.
1028    #[test]
1029    fn a_real_arn_identifier_is_percent_encoded_colons_and_all() {
1030        let arn = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
1031        let path = paths::microvm_image(arn);
1032        assert_eq!(
1033            path,
1034            "/2025-09-09/microvm-images/arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg"
1035        );
1036        assert!(
1037            !path["/2025-09-09/microvm-images/".len()..].contains(':'),
1038            "an unencoded colon in a path segment is a signature mismatch: {path}"
1039        );
1040    }
1041
1042    /// The encoder still encodes `/`, which is the property that matters even though a real
1043    /// image ARN carries none.
1044    ///
1045    /// Kept as its own case rather than folded into the test above, because the input here
1046    /// is a *shape the service does not use* and labelling it "an ARN" is what let the slash
1047    /// form look load-bearing for twelve fakes. An unencoded slash would split into extra
1048    /// path segments addressing something else, and other identifiers this encoder sees
1049    /// (`imageVersion`, a `nextToken` cursor) genuinely can contain one.
1050    #[test]
1051    fn the_segment_encoder_encodes_slashes_wherever_they_come_from() {
1052        assert_eq!(paths::encode_segment("a/b"), "a%2Fb");
1053        let path = paths::microvm_image("has/a/slash");
1054        assert_eq!(path, "/2025-09-09/microvm-images/has%2Fa%2Fslash");
1055        assert!(
1056            !path["/2025-09-09/microvm-images/".len()..].contains('/'),
1057            "an encoded identifier contributes no path separators: {path}"
1058        );
1059    }
1060
1061    /// The API version in the path is the constant the drift gate checks, not a second
1062    /// literal. Two copies of `2025-09-09` are two things to update, and the one that gets
1063    /// missed is the one nothing compiles against.
1064    #[test]
1065    fn the_path_version_is_the_pinned_model_version() {
1066        assert_eq!(API_PATH_VERSION, crate::constants::MODEL_API_VERSION);
1067        assert!(paths::microvms().starts_with("/2025-09-09/"));
1068    }
1069
1070    /// The signing name is `lambda`, from the model's `signingName`. A signature computed
1071    /// for `lambda-microvms` is rejected in a way that reads like bad credentials.
1072    #[test]
1073    fn the_signing_name_is_lambda_not_lambda_microvms() {
1074        assert_eq!(SIGNING_NAME, "lambda");
1075    }
1076
1077    /// TRAP-6's diagnostic, on the exact response shape the trap produces: 403 with a
1078    /// null message. The message has to name the region cause *and* say the field was
1079    /// null, because "AccessDeniedException" alone is what sends a reader to the IAM
1080    /// console.
1081    #[test]
1082    fn a_null_message_access_denied_names_the_region_trap() {
1083        let reply = Reply {
1084            status: 403,
1085            body: br#"{"message": null}"#.to_vec(),
1086            error_type: None,
1087        };
1088        let error = classify_failure("ListMicrovms", &reply);
1089        assert_eq!(error.kind(), ErrorKind::Credentials);
1090        let message = error.to_string();
1091        assert!(message.contains("null"), "{message}");
1092        assert!(message.contains("region"), "{message}");
1093        assert!(message.contains("does not carry MicroVMs"), "{message}");
1094        assert!(
1095            message.contains("Check the region before the policy"),
1096            "the remedy has to be the region, not the policy: {message}"
1097        );
1098    }
1099
1100    /// A 403 *with* a message is a genuine denial and says so, so the two are not
1101    /// conflated. A classifier that named the region trap on every 403 would be as
1102    /// misleading in the other direction.
1103    #[test]
1104    fn a_403_with_a_message_is_reported_as_a_genuine_denial() {
1105        let reply = Reply {
1106            status: 403,
1107            body: br#"{"message": "User: arn:aws:iam::1:user/u is not authorized"}"#.to_vec(),
1108            error_type: None,
1109        };
1110        let error = classify_failure("RunMicrovm", &reply);
1111        assert_eq!(error.kind(), ErrorKind::Credentials);
1112        let message = error.to_string();
1113        assert!(message.contains("not authorized"), "{message}");
1114        assert!(
1115            message.contains("genuine denial"),
1116            "must distinguish itself from the null-message case: {message}"
1117        );
1118        assert!(
1119            !message.contains("does not carry MicroVMs"),
1120            "must not blame the region when the service named a principal: {message}"
1121        );
1122    }
1123
1124    /// A 400 is the service refusing a value this client should have refused first, so the
1125    /// message names the validation refusal and the local checks it got past, not the
1126    /// catch-all for a status the model doesn't declare.
1127    #[test]
1128    fn a_400_names_the_validation_refusal_and_the_local_checks_it_passed() {
1129        let reply = Reply {
1130            status: 400,
1131            body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1132            error_type: None,
1133        };
1134        let error = classify_failure("RunMicrovm", &reply);
1135        assert_eq!(error.kind(), ErrorKind::Platform);
1136        let message = error.to_string();
1137        assert!(message.contains("ValidationException"), "{message}");
1138        assert!(message.contains("checked locally"), "{message}");
1139        assert!(!message.contains("unmodeled status"), "{message}");
1140    }
1141
1142    /// The service's throttle as it arrived from `ListMicrovms` on 2026-10-02: HTTP 400,
1143    /// named `ThrottlingException` only by the `x-amzn-ErrorType` header, with a null
1144    /// message. Classified by status it read as a validation refusal and was never
1145    /// retried, which failed every listing past the account's page rate.
1146    ///
1147    /// **Falsification** — run 2026-10-02. Guard the classifier's header arm with `false`, so
1148    /// the status alone decides again, and this fails on the retryability assertion with the
1149    /// `ValidationException` explanation (`guards:fire -- --only
1150    /// control-throttle-classified-by-status`). Restored.
1151    #[test]
1152    fn a_throttle_named_by_the_error_type_header_is_retryable_whatever_the_status() {
1153        let reply = Reply {
1154            status: 400,
1155            body: br#"{"message":null,"throttlingReasons":null}"#.to_vec(),
1156            error_type: Some("ThrottlingException".to_string()),
1157        };
1158        let error = classify_failure("ListMicrovms", &reply);
1159        assert!(error.retryable(), "{error}");
1160        assert_eq!(error.kind(), ErrorKind::Retryable);
1161        let message = error.to_string();
1162        assert!(message.contains("ThrottlingException"), "{message}");
1163        assert!(message.contains("HTTP 400"), "{message}");
1164        assert!(!message.contains("ValidationException"), "{message}");
1165    }
1166
1167    /// Another error name on a 400 leaves the status rule alone: the header decides only
1168    /// for the throttle, so a genuine validation refusal still isn't retried.
1169    #[test]
1170    fn a_400_naming_another_error_type_is_still_a_validation_refusal() {
1171        let reply = Reply {
1172            status: 400,
1173            body: br#"{"message": "1 validation error detected"}"#.to_vec(),
1174            error_type: Some("ValidationException".to_string()),
1175        };
1176        let error = classify_failure("RunMicrovm", &reply);
1177        assert!(!error.retryable(), "{error}");
1178        assert!(error.to_string().contains("checked locally"), "{error}");
1179    }
1180
1181    /// The header's name is what precedes the first `:` and follows the first `#`, the
1182    /// rest-json rule, on the value the service sent and on the namespaced form.
1183    ///
1184    /// **Falsification** — run 2026-10-02. Keep the whole header instead of what precedes the
1185    /// `:`, and the service's own value comes back with its URI suffix and fails the first
1186    /// case (`guards:fire -- --only control-error-type-keeps-the-uri`). Restored.
1187    #[test]
1188    fn the_error_type_name_drops_the_uri_suffix_and_the_namespace() {
1189        let cases = [
1190            (
1191                "ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/",
1192                "ThrottlingException",
1193            ),
1194            (
1195                "aws.protocoltests.restjson#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate/",
1196                "FooError",
1197            ),
1198            (
1199                "com.amazonaws.lambda#ResourceNotFoundException",
1200                "ResourceNotFoundException",
1201            ),
1202            ("ConflictException", "ConflictException"),
1203        ];
1204        for (header, name) in cases {
1205            assert_eq!(error_type_name(header), name, "{header}");
1206        }
1207    }
1208
1209    /// Only the throttle and the 5xx family are retryable. A retried 409 would spin
1210    /// against an image in CREATING, and a retried 400 would send the same rejected value
1211    /// five more times.
1212    #[test]
1213    fn only_throttles_and_server_faults_are_retryable() {
1214        let cases = [
1215            (400, false),
1216            (402, false),
1217            (403, false),
1218            (404, false),
1219            (409, false),
1220            (429, true),
1221            (500, true),
1222            (502, true),
1223            (503, true),
1224        ];
1225        for (status, retryable) in cases {
1226            let reply = Reply {
1227                status,
1228                body: br#"{"message": "detail"}"#.to_vec(),
1229                error_type: None,
1230            };
1231            let error = classify_failure("GetMicrovm", &reply);
1232            assert_eq!(error.retryable(), retryable, "HTTP {status}");
1233        }
1234    }
1235
1236    /// A control-plane failure carries **no** `WireKind`: nothing reached the in-VM
1237    /// daemon, so there is no daemon-chosen status to report and the CLI's `data.kind`
1238    /// must be absent rather than invented.
1239    #[test]
1240    fn a_control_plane_failure_carries_no_daemon_wire_kind() {
1241        for status in [400, 403, 404, 409, 429, 500] {
1242            let reply = Reply {
1243                status,
1244                body: Vec::new(),
1245                error_type: None,
1246            };
1247            assert_eq!(
1248                classify_failure("GetMicrovm", &reply).wire_kind(),
1249                None,
1250                "HTTP {status} never reached the daemon"
1251            );
1252        }
1253    }
1254
1255    /// An unparseable error body still classifies by status rather than failing. A service
1256    /// answering an HTML error page or a truncated body must not turn a 429 into an
1257    /// unexpected error, which would stop the retry.
1258    #[test]
1259    fn an_unparseable_error_body_still_classifies_by_status() {
1260        let reply = Reply {
1261            status: 429,
1262            body: b"<html>Too Many Requests</html>".to_vec(),
1263            error_type: None,
1264        };
1265        let error = classify_failure("RunMicrovm", &reply);
1266        assert!(error.retryable(), "a throttle is a throttle");
1267        assert!(error.to_string().contains("no message"), "{error}");
1268    }
1269
1270    /// A body-parse failure names the operation and shows the body, because a rest-json
1271    /// parse error with the text withheld cannot distinguish a renamed member from a
1272    /// truncated response.
1273    #[test]
1274    fn a_response_that_does_not_parse_reports_the_body() {
1275        let reply = Reply {
1276            status: 200,
1277            body: br#"{"microvmId": 42}"#.to_vec(),
1278            error_type: None,
1279        };
1280        let error = reply
1281            .json::<crate::control::ops::MicrovmResponseWire>("GetMicrovm")
1282            .expect_err("microvmId is a string shape");
1283        let message = error.to_string();
1284        assert!(message.contains("GetMicrovm"), "{message}");
1285        assert!(message.contains("microvmId"), "{message}");
1286        assert_eq!(error.kind(), ErrorKind::Platform);
1287    }
1288
1289    /// A `POST` with no body members sends `{}` rather than nothing: an empty POST and a
1290    /// POST of `{}` are different canonical requests, and rest-json services answer the
1291    /// former inconsistently.
1292    #[test]
1293    fn a_bodyless_post_sends_an_empty_json_object() {
1294        let call = Call::post_empty("SuspendMicrovm", paths::suspend("mvm-1"));
1295        assert_eq!(call.body.as_deref(), Some(&b"{}"[..]));
1296        assert_eq!(call.method, Method::Post);
1297    }
1298
1299    /// A `GET` and a `DELETE` carry no body at all, and the signed empty payload is what
1300    /// `body_bytes` yields.
1301    #[test]
1302    fn a_get_and_a_delete_carry_no_body() {
1303        let get = Call::get("GetMicrovm", paths::microvm("mvm-1"));
1304        assert_eq!(get.body, None);
1305        assert_eq!(get.body_bytes(), b"");
1306
1307        let delete = Call::delete("TerminateMicrovm", paths::microvm("mvm-1"));
1308        assert_eq!(delete.body, None);
1309        assert_eq!(delete.method, Method::Delete);
1310    }
1311
1312    /// The methods, spelled as the canonical request needs them: uppercase.
1313    #[test]
1314    fn the_methods_are_the_ones_the_model_uses_spelled_uppercase() {
1315        assert_eq!(Method::Get.as_str(), "GET");
1316        assert_eq!(Method::Post.as_str(), "POST");
1317        assert_eq!(Method::Patch.as_str(), "PATCH");
1318        assert_eq!(Method::Delete.as_str(), "DELETE");
1319    }
1320
1321    /// A `PATCH` carries its serialized body and is **not** a `POST`.
1322    ///
1323    /// The distinction is load-bearing rather than cosmetic: `UpdateMicrovmImageVersion` is the
1324    /// model's only `PATCH`, and a `POST` to the version path is a route the service does not
1325    /// declare — so the failure would be a 404 that reads as a missing version rather than as a
1326    /// wrong method. The body assertion is here too, because a `PATCH` whose body was dropped
1327    /// would be a request with no `status` member at all.
1328    ///
1329    /// **Falsification** — set `method: Method::Post` in `patch_json` and the method assertion
1330    /// goes red; the path and body assertions would still pass, which is why the method is
1331    /// asserted separately.
1332    #[test]
1333    fn a_patch_carries_its_body_and_is_not_a_post() {
1334        let call = Call::patch_json(
1335            "UpdateMicrovmImageVersion",
1336            paths::image_version("img", "2.0"),
1337            &crate::control::ops::UpdateImageVersionWire {
1338                status: crate::control::ops::VersionStatus::Inactive,
1339            },
1340        )
1341        .expect("serialises");
1342
1343        assert_eq!(call.method, Method::Patch);
1344        assert_ne!(
1345            call.method,
1346            Method::Post,
1347            "a POST to the version path is a route the model does not declare"
1348        );
1349        assert_eq!(call.path, "/2025-09-09/microvm-images/img/versions/2.0");
1350        assert_eq!(
1351            call.body.as_deref(),
1352            Some(&br#"{"status":"INACTIVE"}"#[..]),
1353            "the one member the request has must reach the body"
1354        );
1355        assert_eq!(call.operation, "UpdateMicrovmImageVersion");
1356    }
1357}