Skip to main content

microvms_core/control/
transport.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The signed rest-json transport, and the seam a test replaces it at.
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 std::time::SystemTime;
37
38use crate::error::{Error, ErrorKind};
39use crate::region::Region;
40
41/// The pinned API version, which is also the first path segment of every operation.
42///
43/// Read from [`crate::constants::MODEL_API_VERSION`] rather than written again, so the
44/// drift gate's version check and the URLs cannot disagree: a client signing
45/// `/2025-09-09/...` while claiming to implement a different model is a client whose
46/// constraint checks were verified against the wrong thing.
47const API_PATH_VERSION: &str = crate::constants::MODEL_API_VERSION;
48
49/// The SigV4 signing name, from the model's `signingName`.
50///
51/// `lambda`, not `lambda-microvms`. The `endpointPrefix` is also `lambda`, which is why
52/// an endpoint resolves for every AWS region and a client constructs happily for a region
53/// that does not carry MicroVMs (TRAP-6, `crate::region`).
54const SIGNING_NAME: &str = "lambda";
55
56/// One control-plane call: method, path, optional JSON body.
57///
58/// A struct rather than a long parameter list because the recorder asserts on it as a
59/// whole, and because `method` and `path` next to each other is how a reader checks a
60/// path against the model's `http` trait.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct Call {
63    /// The operation name, for diagnostics and for the recorder's ledger.
64    ///
65    /// Carried rather than derived from the path: the recorder's TRAP-11 assertion is
66    /// "an exec lifecycle emits no call named `CreateMicrovmShellAuthToken`", and
67    /// matching that on a path is a substring test that a renamed route would slip past.
68    pub operation: &'static str,
69    /// `GET`, `POST`, `PATCH`, or `DELETE` — the four the model's `http` traits use for the
70    /// operations this client implements.
71    pub method: Method,
72    /// The path **after** the host, with the API version and every URI parameter already
73    /// percent-encoded. Includes the query string when the operation has one.
74    pub path: String,
75    /// The rest-json body, or `None` for an operation the model gives no body members.
76    pub body: Option<Vec<u8>>,
77}
78
79/// The HTTP methods the model's operations use.
80///
81/// An enum so a call site cannot pass `"post"` and have the canonical request silently
82/// disagree with the wire method.
83///
84/// # `Patch` is one operation's, and only one
85///
86/// The model uses `PATCH` for exactly `UpdateMicrovmImageVersion` and `PUT` for exactly
87/// `UpdateMicrovmImage`. `Put` is deliberately absent: this client does not implement that
88/// operation, and a variant nothing constructs is a variant that suggests it does.
89/// `PATCH` is not interchangeable with `POST` here — a `POST` to the version path is a route
90/// the service does not declare, and the answer is a 404 that reads as a missing version
91/// rather than as a wrong method.
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93pub enum Method {
94    Get,
95    Post,
96    Patch,
97    Delete,
98}
99
100impl Method {
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Method::Get => "GET",
104            Method::Post => "POST",
105            Method::Patch => "PATCH",
106            Method::Delete => "DELETE",
107        }
108    }
109}
110
111impl Call {
112    /// A `GET` with no body.
113    pub fn get(operation: &'static str, path: impl Into<String>) -> Self {
114        Self {
115            operation,
116            method: Method::Get,
117            path: path.into(),
118            body: None,
119        }
120    }
121
122    /// A `POST` carrying a serialized rest-json body.
123    pub fn post_json<T: serde::Serialize>(
124        operation: &'static str,
125        path: impl Into<String>,
126        body: &T,
127    ) -> Result<Self, Error> {
128        let body = serde_json::to_vec(body).map_err(|error| {
129            Error::new(
130                ErrorKind::Unexpected,
131                format!("could not serialize the {operation} request body: {error}"),
132            )
133        })?;
134        Ok(Self {
135            operation,
136            method: Method::Post,
137            path: path.into(),
138            body: Some(body),
139        })
140    }
141
142    /// A `PATCH` carrying a serialized rest-json body.
143    ///
144    /// The same shape as [`Call::post_json`] and written beside it rather than folded into a
145    /// method-taking constructor, for the reason [`Method`]'s docs give: the method is part of
146    /// the route the model declares, and a `with_method(Method::Patch)` builder is one a call
147    /// site can forget to call. `UpdateMicrovmImageVersion` is the only operation that reaches
148    /// this.
149    pub fn patch_json<T: serde::Serialize>(
150        operation: &'static str,
151        path: impl Into<String>,
152        body: &T,
153    ) -> Result<Self, Error> {
154        let body = serde_json::to_vec(body).map_err(|error| {
155            Error::new(
156                ErrorKind::Unexpected,
157                format!("could not serialize the {operation} request body: {error}"),
158            )
159        })?;
160        Ok(Self {
161            operation,
162            method: Method::Patch,
163            path: path.into(),
164            body: Some(body),
165        })
166    }
167
168    /// A `POST` with no body members. `SuspendMicrovm` and `ResumeMicrovm` are both this
169    /// shape: their only member is the URI-located identifier.
170    ///
171    /// The body is `Some(b"{}")` rather than `None`, because an empty POST and a POST of
172    /// `{}` are different canonical requests and rest-json services answer the former
173    /// inconsistently.
174    pub fn post_empty(operation: &'static str, path: impl Into<String>) -> Self {
175        Self {
176            operation,
177            method: Method::Post,
178            path: path.into(),
179            body: Some(b"{}".to_vec()),
180        }
181    }
182
183    /// A `DELETE` with no body.
184    pub fn delete(operation: &'static str, path: impl Into<String>) -> Self {
185        Self {
186            operation,
187            method: Method::Delete,
188            path: path.into(),
189            body: None,
190        }
191    }
192
193    /// The body as bytes, empty when there is none — which is what gets signed.
194    fn body_bytes(&self) -> &[u8] {
195        self.body.as_deref().unwrap_or(&[])
196    }
197}
198
199/// What the control plane answered: a status and a raw body.
200///
201/// Raw bytes rather than a deserialized value because the error path has to read a body
202/// the success types cannot hold, and because TRAP-6's null `message` field is only
203/// visible to something that did not already require a `String` there.
204#[derive(Clone, Debug)]
205pub struct Reply {
206    pub status: u16,
207    pub body: Vec<u8>,
208}
209
210impl Reply {
211    /// Deserializes the body as `T`, or reports the failure with the body attached.
212    ///
213    /// The body goes in the message because a rest-json parse failure with the text
214    /// withheld is unactionable: the reader cannot tell a renamed member from a truncated
215    /// response. Truncated to 512 bytes, since a list response can be large and the first
216    /// 512 characters always contain the member that failed.
217    pub fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
218        serde_json::from_slice(&self.body).map_err(|error| {
219            let text = String::from_utf8_lossy(&self.body);
220            let shown: String = text.chars().take(512).collect();
221            Error::new(
222                ErrorKind::Platform,
223                format!(
224                    "could not read the {operation} response ({error}). The service model for \
225                     {API_PATH_VERSION} is what this client's shapes are transcribed from, so a \
226                     member that has moved is a drift-gate failure rather than a transport bug. \
227                     Body: {shown}"
228                ),
229            )
230            .with_source(error)
231        })
232    }
233
234    /// Whether the status is a success. rest-json operations here answer 200 or 201.
235    fn is_success(&self) -> bool {
236        (200..300).contains(&self.status)
237    }
238}
239
240/// The one seam every operation goes through.
241///
242/// One method, so a fake is a few lines and there is no partially-overridden transport
243/// whose behaviour is half real. `&self` rather than `&mut self` so the recorder can be
244/// shared behind an `Arc` while a lifecycle runs.
245pub trait Transport: Send + Sync {
246    /// Performs `call`, returning whatever the far side answered.
247    ///
248    /// A transport-level failure (no status at all) is an `Err`; a 4xx or 5xx is an
249    /// `Ok(Reply)` carrying the status, because classifying a status is
250    /// [`classify_failure`]'s job and a transport that classified would be a second
251    /// place the taxonomy lives.
252    fn send(
253        &self,
254        call: Call,
255    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>;
256
257    /// Resolves the credentials the next [`Transport::send`] would sign with, sending nothing
258    /// to the service (BIND-15's credentials check).
259    ///
260    /// Resolving may reach an SSO, credential-process, or instance-metadata source, none of
261    /// which bills. The default answers `Ok`: a transport that does not sign (every scripted
262    /// one) has nothing to resolve.
263    fn resolve_credentials(
264        &self,
265    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
266        Box::pin(async { Ok(()) })
267    }
268}
269
270/// Sends `call` through `transport`, retrying the retryable statuses, and turns a failure
271/// status into a classified [`Error`].
272///
273/// # Which failures retry
274///
275/// 429 (`ThrottlingException`), 500 (`InternalServerException`), and any other 5xx, plus
276/// a transport error that produced no status. AWS standard-mode semantics, matched by
277/// hand because the smithy retry pieces are coupled to an orchestrator this client does
278/// not have.
279///
280/// # Why retrying the mutating operations is safe
281///
282/// Every mutating operation this client sends carries a `clientToken`, so a retry is
283/// idempotent by the service's own contract — which is the same property that makes the
284/// token dangerous to derive (TRAP-1). `SuspendMicrovm`/`ResumeMicrovm`/`TerminateMicrovm`
285/// take no token and are retried anyway: they are state transitions whose second
286/// application is a no-op or a `ConflictException`, and a conflict is not retried.
287pub async fn send_with_retry(transport: &dyn Transport, call: Call) -> Result<Reply, Error> {
288    use backon::{ExponentialBuilder, Retryable};
289
290    let operation = call.operation;
291    let attempt = || {
292        let call = call.clone();
293        async move {
294            let reply = transport.send(call).await?;
295            if reply.is_success() {
296                return Ok(reply);
297            }
298            Err(classify_failure(operation, &reply))
299        }
300    };
301
302    attempt
303        .retry(
304            ExponentialBuilder::default()
305                .with_jitter()
306                .with_min_delay(std::time::Duration::from_millis(200))
307                .with_max_delay(std::time::Duration::from_secs(20))
308                .with_max_times(5),
309        )
310        .when(Error::retryable)
311        .await
312}
313
314/// [`send_with_retry`], answering `Ok` for the statuses in `accept` as well as for a
315/// success.
316///
317/// For a call whose failure status is an answer rather than an error: `GetMicrovmImage`'s 404
318/// is "there is no image under this name", which `ensure_image` reads as a state
319/// ([`crate::control::ensure`]). Retries are unchanged — a throttle or a 5xx is still
320/// retried — and every other status is still classified by [`classify_failure`].
321pub async fn send_accepting(
322    transport: &dyn Transport,
323    call: Call,
324    accept: &[u16],
325) -> Result<Reply, Error> {
326    use backon::{ExponentialBuilder, Retryable};
327
328    let operation = call.operation;
329    let attempt = || {
330        let call = call.clone();
331        async move {
332            let reply = transport.send(call).await?;
333            if reply.is_success() || accept.contains(&reply.status) {
334                return Ok(reply);
335            }
336            Err(classify_failure(operation, &reply))
337        }
338    };
339
340    attempt
341        .retry(
342            ExponentialBuilder::default()
343                .with_jitter()
344                .with_min_delay(std::time::Duration::from_millis(200))
345                .with_max_delay(std::time::Duration::from_secs(20))
346                .with_max_times(5),
347        )
348        .when(Error::retryable)
349        .await
350}
351
352/// Turns a failure status and body into an [`Error`] of the right kind.
353///
354/// # The statuses are the control plane's, not the daemon's
355///
356/// [`crate::error::WireKind`] is the *daemon's* status discipline and deliberately has no
357/// generic 4xx fallback, because the daemon's 400-versus-404 distinction is load-bearing.
358/// None of that applies here: these seven statuses come from the modeled exception shapes
359/// (`ValidationException` 400, `ServiceQuotaExceededException` 402,
360/// `AccessDeniedException` 403, `ResourceNotFoundException` 404, `ConflictException` 409,
361/// `ThrottlingException` 429, `InternalServerException` 500). So a control-plane failure
362/// carries an [`ErrorKind`] and **no** `WireKind` — which is what `error.rs` already
363/// documents: "`None` for every local reject and every control-plane failure — those never
364/// reached the in-VM daemon".
365///
366/// # 403 and the null message
367///
368/// A 403 whose `message` is null is the unsupported-region signature (TRAP-6). The
369/// message says so, because the alternative — forwarding a null as "AccessDenied: " — is
370/// precisely what sends someone to audit an IAM policy that is fine.
371pub fn classify_failure(operation: &str, reply: &Reply) -> Error {
372    let parsed: crate::control::ops::ServiceErrorWire =
373        serde_json::from_slice(&reply.body).unwrap_or_default();
374    let detail = parsed.message.as_deref().unwrap_or("").trim().to_string();
375
376    let (kind, explanation) = match reply.status {
377        400 => (
378            ErrorKind::Platform,
379            "ValidationException — the service refused a value. Every constraint the pinned \
380             service model states on a member this client sends is checked locally before the \
381             call — min as well as max, pattern, and enum, because nothing else validates a \
382             request here: this client signs with aws-sigv4 and sends with reqwest, so botocore's \
383             VALIDATED_METADATA_ATTRS never applies to it. So reaching this status means a \
384             constraint this client does not yet check, and closing it is a guard in \
385             microvms-core/src/control plus a line in scripts/check-model-drift.py."
386                .to_string(),
387        ),
388        402 => (
389            ErrorKind::Platform,
390            "ServiceQuotaExceededException — an account limit, not a request defect.".to_string(),
391        ),
392        403 if detail.is_empty() => (
393            ErrorKind::Credentials,
394            "AccessDeniedException with an empty or null message field. Measured 2026-08-07: this \
395             is the signature of a region that does not carry MicroVMs, and it is \
396             indistinguishable from a genuine IAM denial except that a real denial names the \
397             principal and the action (docs/PLATFORM.md, 'Calling an unpriced region returns \
398             AccessDeniedException with a null message'). Check the region before the policy."
399                .to_string(),
400        ),
401        403 => (
402            ErrorKind::Credentials,
403            "AccessDeniedException, with a message — so this is a genuine denial rather than the \
404             null-message region trap."
405                .to_string(),
406        ),
407        404 => (
408            ErrorKind::Platform,
409            "ResourceNotFoundException — the image or MicroVM does not exist.".to_string(),
410        ),
411        409 => (
412            ErrorKind::Platform,
413            "ConflictException — the resource is in a state that forbids this call. An image in \
414             CREATING refuses deletion, which is why teardown retries."
415                .to_string(),
416        ),
417        429 => (
418            ErrorKind::Retryable,
419            "ThrottlingException — retry the identical request.".to_string(),
420        ),
421        status if status >= 500 => (
422            ErrorKind::Retryable,
423            "InternalServerException — a service fault, retry the identical request.".to_string(),
424        ),
425        _ => (
426            ErrorKind::Platform,
427            "an unmodeled status: the service model declares 400, 402, 403, 404, 409, 429, and \
428             500 for these operations."
429                .to_string(),
430        ),
431    };
432
433    let said = if detail.is_empty() {
434        "the service sent no message".to_string()
435    } else {
436        format!("the service said {detail:?}")
437    };
438    Error::new(
439        kind,
440        format!(
441            "{operation} failed with HTTP {}: {said}. {explanation}",
442            reply.status
443        ),
444    )
445}
446
447/// The real transport: resolve credentials, sign SigV4, send with reqwest.
448pub struct SignedTransport {
449    region: Region,
450    endpoint: String,
451    credentials: aws_credential_types::provider::SharedCredentialsProvider,
452    http: reqwest::Client,
453}
454
455impl std::fmt::Debug for SignedTransport {
456    /// Hand-written because a derived one would print the credentials provider, and the
457    /// provider's own `Debug` is not something this crate controls.
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        f.debug_struct("SignedTransport")
460            .field("region", &self.region)
461            .field("endpoint", &self.endpoint)
462            .finish_non_exhaustive()
463    }
464}
465
466impl SignedTransport {
467    /// Resolves the default credential chain for `region` and builds the HTTP client.
468    ///
469    /// # Why the chain rather than explicit keys
470    ///
471    /// Because the credentials a developer actually has are in SSO, a credential process,
472    /// or an instance profile, and re-implementing that resolution is how a client ends up
473    /// working only on the machine it was written on. `aws-config`'s default chain is the
474    /// whole reason to depend on it at all — the manifest turns off its HTTP client, since
475    /// reqwest is the one here, but keeps `sso` and `credentials-process`.
476    ///
477    /// The provider is held rather than the resolved credentials: instance-profile creds
478    /// are temporary, so each request re-resolves and picks up a rotation. That is why
479    /// this is a provider field and not a `Credentials` field.
480    pub async fn new(region: Region) -> Result<Self, Error> {
481        let credentials = default_credentials(&region).await?;
482        // Generous rather than tight: a control-plane call is not a hot path, and a timeout
483        // shorter than the service's own tail latency turns a slow answer into a retry storm
484        // against an operation that may not be idempotent.
485        let http = http_client(std::time::Duration::from_secs(60))?;
486
487        Ok(Self {
488            endpoint: endpoint_for(&region),
489            region,
490            credentials,
491            http,
492        })
493    }
494
495    /// The URL this transport would send `call` to. Public for the test that checks a path
496    /// against the model without needing credentials.
497    fn url(&self, call: &Call) -> String {
498        format!("{}{}", self.endpoint, call.path)
499    }
500}
501
502/// The default credential chain for `region`, as a provider re-asked per request.
503///
504/// Shared by [`SignedTransport`] and [`crate::control::SignedBuildServices`], so the STS and
505/// S3 calls `ensure_image` makes resolve exactly the identity the control-plane calls do.
506pub(crate) async fn default_credentials(
507    region: &Region,
508) -> Result<aws_credential_types::provider::SharedCredentialsProvider, Error> {
509    let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
510        .region(aws_config::Region::new(region.as_str().to_string()))
511        .load()
512        .await;
513    config.credentials_provider().ok_or_else(|| {
514        Error::new(
515            ErrorKind::Credentials,
516            "no credentials provider resolved. The default chain looks at environment \
517             variables, the shared config files, SSO, a credential process, then the EC2 \
518             instance metadata service; none of them answered. `aws sts get-caller-identity` \
519             is the cheapest way to see the same failure.",
520        )
521    })
522}
523
524/// A reqwest client with a 10-second connect timeout and the given overall `timeout`.
525pub(crate) fn http_client(timeout: std::time::Duration) -> Result<reqwest::Client, Error> {
526    reqwest::Client::builder()
527        .connect_timeout(std::time::Duration::from_secs(10))
528        .timeout(timeout)
529        .build()
530        .map_err(|error| {
531            Error::new(
532                ErrorKind::Precondition,
533                format!("could not build the HTTP client: {error}"),
534            )
535            .with_source(error)
536        })
537}
538
539/// Resolves the current credentials from `provider`, naming `operation` on failure.
540pub(crate) async fn resolve_credentials(
541    provider: &aws_credential_types::provider::SharedCredentialsProvider,
542    operation: &str,
543) -> Result<aws_credential_types::Credentials, Error> {
544    use aws_credential_types::provider::ProvideCredentials as _;
545
546    provider.provide_credentials().await.map_err(|error| {
547        Error::new(
548            ErrorKind::Credentials,
549            format!(
550                "could not resolve credentials for {operation}: {error}. Waiting will not fix \
551                 this — the identity is wrong or absent."
552            ),
553        )
554        .with_source(error)
555    })
556}
557
558/// `https://lambda.<region>.amazonaws.com`, from the model's `endpointPrefix`.
559///
560/// The prefix is `lambda` rather than `lambda-microvms`, which is why this resolves for
561/// every AWS region including the ones that answer the null-message denial.
562pub fn endpoint_for(region: &Region) -> String {
563    format!("https://{SIGNING_NAME}.{}.amazonaws.com", region.as_str())
564}
565
566impl Transport for SignedTransport {
567    fn resolve_credentials(
568        &self,
569    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
570        Box::pin(async move {
571            use aws_credential_types::provider::ProvideCredentials as _;
572            self.credentials
573                .provide_credentials()
574                .await
575                .map(|_| ())
576                .map_err(|error| {
577                    Error::new(
578                        ErrorKind::Credentials,
579                        format!(
580                            "the default credential chain resolved no credentials for {}: \
581                             {error}. It looks at environment variables, the shared config \
582                             files, SSO, a credential process, then the instance metadata \
583                             service.",
584                            self.region
585                        ),
586                    )
587                    .with_source(error)
588                })
589        })
590    }
591
592    fn send(
593        &self,
594        call: Call,
595    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>
596    {
597        Box::pin(async move {
598            // Re-resolved per request so an instance-profile rotation is picked up. The
599            // provider caches internally, so this is not a metadata call per request.
600            let credentials = resolve_credentials(&self.credentials, call.operation).await?;
601
602            let url = self.url(&call);
603            let body = call.body_bytes().to_vec();
604
605            // Built before signing, because the signature covers the headers that are on
606            // it: content-type is signed, and adding one afterwards invalidates it.
607            let mut request = http::Request::builder()
608                .method(call.method.as_str())
609                .uri(&url)
610                .header("content-type", "application/json")
611                .body(body.clone())
612                .map_err(|error| {
613                    Error::new(
614                        ErrorKind::Unexpected,
615                        format!("could not build the {} request: {error}", call.operation),
616                    )
617                    .with_source(error)
618                })?;
619
620            sign_in_place(&mut request, &credentials, &self.region)?;
621
622            let (parts, body) = request.into_parts();
623            let request = http::Request::from_parts(parts, reqwest::Body::from(body));
624            let request = reqwest::Request::try_from(request).map_err(|error| {
625                Error::new(
626                    ErrorKind::Unexpected,
627                    format!("could not convert the signed request: {error}"),
628                )
629                .with_source(error)
630            })?;
631
632            // A failure here produced no status, so it says nothing about service state:
633            // Retryable, matching the daemon transport's own rule.
634            let response = self.http.execute(request).await.map_err(|error| {
635                Error::new(
636                    ErrorKind::Retryable,
637                    format!(
638                        "the {} request to {url} did not complete: {error}",
639                        call.operation
640                    ),
641                )
642                .with_source(error)
643            })?;
644
645            let status = response.status().as_u16();
646            let body = response
647                .bytes()
648                .await
649                .map_err(|error| {
650                    Error::new(
651                        ErrorKind::Retryable,
652                        format!(
653                            "could not read the {} response body: {error}",
654                            call.operation
655                        ),
656                    )
657                    .with_source(error)
658                })?
659                .to_vec();
660
661            Ok(Reply { status, body })
662        })
663    }
664}
665
666/// Signs `request` in place with SigV4 for `region`.
667///
668/// Split out so the signing step is one readable unit and so the test below can sign a
669/// request with static credentials and inspect the headers, which is the only way to check
670/// the signing name and region without a live call.
671fn sign_in_place(
672    request: &mut http::Request<Vec<u8>>,
673    credentials: &aws_credential_types::Credentials,
674    region: &Region,
675) -> Result<(), Error> {
676    sign_for(
677        request,
678        credentials,
679        region,
680        SIGNING_NAME,
681        aws_sigv4::http_request::SigningSettings::default(),
682    )
683}
684
685/// Signs `request` in place with SigV4 for `service` in `region`, under `settings`.
686///
687/// The control plane signs as `lambda` with the default settings; the build services sign
688/// as `sts`, and as `s3` with S3's own settings (a single-encoded path and a signed
689/// `x-amz-content-sha256`), which is why the name and settings are parameters here.
690pub(crate) fn sign_for(
691    request: &mut http::Request<Vec<u8>>,
692    credentials: &aws_credential_types::Credentials,
693    region: &Region,
694    service: &str,
695    settings: aws_sigv4::http_request::SigningSettings,
696) -> Result<(), Error> {
697    use aws_sigv4::http_request::{SignableBody, SignableRequest, sign};
698    use aws_sigv4::sign::v4;
699
700    let identity = credentials.clone().into();
701    let params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
702        .identity(&identity)
703        .region(region.as_str())
704        .name(service)
705        .time(SystemTime::now())
706        .settings(settings)
707        .build()
708        .map_err(|error| {
709            Error::new(
710                ErrorKind::Unexpected,
711                format!("could not build SigV4 signing parameters: {error}"),
712            )
713            .with_source(error)
714        })?
715        .into();
716
717    // The headers view has to borrow from `request`, so the signable view is built and
718    // consumed before the mutable borrow that applies the signature.
719    let instructions = {
720        let signable = SignableRequest::new(
721            request.method().as_str(),
722            request.uri().to_string(),
723            request.headers().iter().filter_map(|(name, value)| {
724                // A header whose value is not ASCII cannot be part of a canonical
725                // request. This client sets only content-type, so the filter is a
726                // guard against a future header rather than a live case — and dropping
727                // one is correct: an unsigned header is still sent, it is just not
728                // covered.
729                value.to_str().ok().map(|value| (name.as_str(), value))
730            }),
731            // `Bytes`, never `UnsignedPayload`: non-S3 services reject an unsigned
732            // payload, and an empty body still needs its empty-payload SHA256 signed.
733            SignableBody::Bytes(request.body()),
734        )
735        .map_err(|error| {
736            Error::new(
737                ErrorKind::Unexpected,
738                format!("could not build the signable request: {error}"),
739            )
740            .with_source(error)
741        })?;
742
743        sign(signable, &params)
744            .map_err(|error| {
745                Error::new(
746                    ErrorKind::Credentials,
747                    format!("SigV4 signing failed: {error}"),
748                )
749                .with_source(error)
750            })?
751            .into_parts()
752            .0
753    };
754
755    // `http1x` rather than `http0x`: aws-sigv4's default features are sign-http + http1,
756    // which is the version reqwest 0.13 consumes through TryFrom. Mixing the two http
757    // versions would mean converting on every call.
758    instructions.apply_to_request_http1x(request);
759    Ok(())
760}
761
762/// The operation paths, one function per `http.requestUri` in the model.
763///
764/// Here rather than inlined at each call site so a path is written once and can be read
765/// against the model in one place. Every URI parameter is percent-encoded, because an
766/// image identifier may be an ARN — which contains `:` and `/` — and an unencoded ARN in a
767/// path segment is a different resource.
768pub mod paths {
769    use super::API_PATH_VERSION;
770
771    /// Percent-encodes a URI path segment.
772    ///
773    /// Everything outside RFC 3986's unreserved set is escaped. **`/` is encoded**,
774    /// which is the whole point: an image ARN's slashes would otherwise split into
775    /// extra path segments and address something else. `:` is encoded too — legal
776    /// unencoded in a path per the RFC, but AWS's own SDKs encode it in labels and
777    /// the signature must match whatever is sent.
778    pub fn encode_segment(segment: &str) -> String {
779        use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
780        const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
781            .remove(b'-')
782            .remove(b'_')
783            .remove(b'.')
784            .remove(b'~');
785        utf8_percent_encode(segment, ESCAPED).to_string()
786    }
787
788    /// `POST /2025-09-09/microvm-images`
789    pub fn microvm_images() -> String {
790        format!("/{API_PATH_VERSION}/microvm-images")
791    }
792
793    /// `GET /2025-09-09/microvm-images`, with the listing's query members.
794    ///
795    /// `nameFilter` is the model's server-side **substring** filter — it narrows the
796    /// listing, it does not answer "the image named X", so an exact-match comparison
797    /// still happens in the caller. `nextToken` is the pagination cursor and is opaque,
798    /// which is why both values go through [`encode_segment`]: an opaque token may carry
799    /// `+` or `=`, and an unencoded one desynchronises the SigV4 canonical query from
800    /// the query actually sent.
801    pub fn microvm_images_list(name_filter: Option<&str>, next_token: Option<&str>) -> String {
802        let mut query: Vec<String> = Vec::new();
803        if let Some(filter) = name_filter {
804            query.push(format!("nameFilter={}", encode_segment(filter)));
805        }
806        if let Some(token) = next_token {
807            query.push(format!("nextToken={}", encode_segment(token)));
808        }
809        if query.is_empty() {
810            return microvm_images();
811        }
812        format!("{}?{}", microvm_images(), query.join("&"))
813    }
814
815    /// `GET|DELETE /2025-09-09/microvm-images/{imageIdentifier}`
816    pub fn microvm_image(image: &str) -> String {
817        format!(
818            "/{API_PATH_VERSION}/microvm-images/{}",
819            encode_segment(image)
820        )
821    }
822
823    /// One `nextToken=` query member, appended to `path`, or `path` unchanged.
824    ///
825    /// Shared by the three listings that take nothing else, so the encoding decision is
826    /// made once. The value goes through [`encode_segment`] for the reason
827    /// [`microvm_images_list`] states: a pagination token is **opaque**, so it may carry
828    /// `+`, `/`, or `=`, and an unencoded one desynchronises the SigV4 canonical query
829    /// from the query actually sent — a rejection that reads like bad credentials rather
830    /// than like a malformed URL.
831    fn with_next_token(path: String, next_token: Option<&str>) -> String {
832        match next_token {
833            Some(token) => format!("{path}?nextToken={}", encode_segment(token)),
834            None => path,
835        }
836    }
837
838    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions`, with the pagination
839    /// cursor when there is one.
840    ///
841    /// `maxResults` is not sent: the model caps it at 50 and the service's own default is
842    /// what a caller reading every page wants, so naming a page size would only be a
843    /// second number to keep right. [`image_versions_paged`] is the one caller that does
844    /// name one, and says why.
845    pub fn image_versions(image: &str, next_token: Option<&str>) -> String {
846        image_versions_paged(image, next_token, None)
847    }
848
849    /// [`image_versions`] with an explicit `maxResults`, which **no production path sends**.
850    ///
851    /// # Why a page size exists at all when nothing ships one
852    ///
853    /// Because without it the cursor encoding is unfalsifiable against the real service,
854    /// and that encoding is the one place in this module where being wrong reads as a
855    /// *credentials* failure rather than a URL one. A real `nextToken` is opaque base64
856    /// carrying `+`, `/`, and `=`; an unencoded one desynchronises the SigV4 canonical
857    /// query and the service answers 403. Every test that could catch it went through an
858    /// injected transport, so the fake minted the token and the signer never saw it.
859    ///
860    /// Forcing a real cursor needs a listing with more items than one page, and the
861    /// service's default page is larger than any image's version count here — so the only
862    /// way to make the real service mint a real cursor for a resource that already exists
863    /// is to ask for a smaller page. That is what this is for, and
864    /// `microvms-core/tests/live_pagination.rs` is its caller.
865    ///
866    /// # Why it is not simply a parameter on [`image_versions`]
867    ///
868    /// A page size on the production path is a second number to keep right, and a wrong
869    /// one is invisible: reading every page with `maxResults=1` is correct and slow, which
870    /// is the kind of defect that survives. Keeping it to a separately named function means
871    /// the production call sites cannot acquire one by accident, and a reader of
872    /// [`image_versions`] sees no page size to wonder about.
873    ///
874    /// `max_results` is clamped to the model's `1..=50`, because a value outside it is a
875    /// `ValidationException` and a caller of this function is trying to observe pagination
876    /// rather than to discover the bound.
877    pub fn image_versions_paged(
878        image: &str,
879        next_token: Option<&str>,
880        max_results: Option<u32>,
881    ) -> String {
882        let mut query: Vec<String> = Vec::new();
883        if let Some(size) = max_results {
884            query.push(format!("maxResults={}", size.clamp(1, 50)));
885        }
886        if let Some(token) = next_token {
887            query.push(format!("nextToken={}", encode_segment(token)));
888        }
889        let path = format!("{}/versions", microvm_image(image));
890        if query.is_empty() {
891            return path;
892        }
893        format!("{path}?{}", query.join("&"))
894    }
895
896    /// `GET|PATCH|DELETE /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}`
897    ///
898    /// One path for three operations, because the model gives them one `requestUri` and the
899    /// method is what distinguishes them: `GetMicrovmImageVersion` reads it,
900    /// `UpdateMicrovmImageVersion` patches it, `DeleteMicrovmImageVersion` deletes it. The
901    /// method lives on the [`Call`] rather than in three path functions, so a reader checking
902    /// this against `service-2.json` compares one string.
903    ///
904    /// Built from [`microvm_image`] rather than from [`image_versions`], because that one
905    /// now takes a cursor and none of the three may carry a query member.
906    pub fn image_version(image: &str, version: &str) -> String {
907        format!(
908            "{}/versions/{}",
909            microvm_image(image),
910            encode_segment(version)
911        )
912    }
913
914    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds/{buildId}`
915    ///
916    /// The list path plus one segment. `buildId` is percent-encoded like every other URI
917    /// parameter even though a real one is a UUID: the model types it `NonBlankString`, so it
918    /// is not the encoder's business to assume the shape of a value the service mints.
919    pub fn image_build(image: &str, version: &str, build_id: &str) -> String {
920        format!(
921            "{}/builds/{}",
922            image_version(image, version),
923            encode_segment(build_id)
924        )
925    }
926
927    /// `GET /2025-09-09/managed-microvm-images`, with the pagination cursor when there is one.
928    ///
929    /// A **different route** from [`microvm_images`], not a filter on it: the managed bases
930    /// live under `managed-microvm-images` and their summaries carry a different shape
931    /// (`ManagedMicrovmImageSummary`, three members). Reading the account's own image listing
932    /// would never return one.
933    pub fn managed_microvm_images(next_token: Option<&str>) -> String {
934        with_next_token(
935            format!("/{API_PATH_VERSION}/managed-microvm-images"),
936            next_token,
937        )
938    }
939
940    /// `GET /2025-09-09/managed-microvm-images/{imageIdentifier}/versions`, with the cursor.
941    ///
942    /// `imageIdentifier` here is the managed base's **full ARN**, not its name: measured
943    /// 2026-08-16, `al2023-1` alone answers `ValidationException: Invalid ARN format:
944    /// al2023-1`. That is why [`crate::control::BaseImage::arn`] is what the caller passes —
945    /// and why the wrapper refuses a non-ARN locally, since the rejection names the value
946    /// without saying which member wanted an ARN.
947    pub fn managed_image_versions(image: &str, next_token: Option<&str>) -> String {
948        with_next_token(
949            format!(
950                "/{API_PATH_VERSION}/managed-microvm-images/{}/versions",
951                encode_segment(image)
952            ),
953            next_token,
954        )
955    }
956
957    /// `GET /2025-09-09/microvm-images/{imageIdentifier}/versions/{imageVersion}/builds`,
958    /// with the pagination cursor when there is one.
959    pub fn image_builds(image: &str, version: &str, next_token: Option<&str>) -> String {
960        with_next_token(
961            format!("{}/builds", image_version(image, version)),
962            next_token,
963        )
964    }
965
966    /// `POST /2025-09-09/microvms`
967    ///
968    /// The bare collection path, which is what `RunMicrovm` posts to. `ListMicrovms` reads
969    /// the same route and lives in [`microvms_list`] rather than here, so a launch cannot
970    /// accidentally carry a `nextToken` — the same split [`microvm_images`] and
971    /// [`microvm_images_list`] already make.
972    pub fn microvms() -> String {
973        format!("/{API_PATH_VERSION}/microvms")
974    }
975
976    /// `GET /2025-09-09/microvms`, with the pagination cursor when there is one.
977    pub fn microvms_list(next_token: Option<&str>) -> String {
978        with_next_token(microvms(), next_token)
979    }
980
981    /// [`microvms_list`] with the model's `imageIdentifier` and `imageVersion` query filters.
982    ///
983    /// Keys are emitted in sorted order, which is also SigV4's canonical order.
984    pub fn microvms_list_matching(
985        image_identifier: Option<&str>,
986        image_version: Option<&str>,
987        next_token: Option<&str>,
988    ) -> String {
989        let query: Vec<String> = [
990            ("imageIdentifier", image_identifier),
991            ("imageVersion", image_version),
992            ("nextToken", next_token),
993        ]
994        .into_iter()
995        .filter_map(|(key, value)| value.map(|value| format!("{key}={}", encode_segment(value))))
996        .collect();
997        if query.is_empty() {
998            return microvms();
999        }
1000        format!("{}?{}", microvms(), query.join("&"))
1001    }
1002
1003    /// `GET|DELETE /2025-09-09/microvms/{microvmIdentifier}`
1004    pub fn microvm(id: &str) -> String {
1005        format!("/{API_PATH_VERSION}/microvms/{}", encode_segment(id))
1006    }
1007
1008    /// `POST /2025-09-09/microvms/{microvmIdentifier}/suspend`
1009    pub fn suspend(id: &str) -> String {
1010        format!("{}/suspend", microvm(id))
1011    }
1012
1013    /// `POST /2025-09-09/microvms/{microvmIdentifier}/resume`
1014    pub fn resume(id: &str) -> String {
1015        format!("{}/resume", microvm(id))
1016    }
1017
1018    /// `POST /2025-09-09/microvms/{microvmIdentifier}/auth-token`
1019    pub fn auth_token(id: &str) -> String {
1020        format!("{}/auth-token", microvm(id))
1021    }
1022
1023    /// `POST /2025-09-09/microvms/{microvmIdentifier}/shell-auth-token`
1024    ///
1025    /// The shell token's route, its own path rather than a parameter on
1026    /// [`auth_token`]: the two operations take different bodies (`allowedPorts` exists
1027    /// only on the ordinary one — the shell is not a port), and the exec path must never
1028    /// be one boolean away from minting a shell credential (TRAP-11).
1029    pub fn shell_auth_token(id: &str) -> String {
1030        format!("{}/shell-auth-token", microvm(id))
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    /// The regression that blocked the whole live tier: with aws-config's
1039    /// `default-https-client` off, `load()` panics with "a http_client is
1040    /// required" before any credential question is even asked. Constructing
1041    /// through the real chain must yield a `Result` — either outcome is fine
1042    /// here (this host may or may not have credentials); a panic is the bug.
1043    /// IMDS is the only endpoint this can touch: link-local, free, and fast.
1044    #[tokio::test]
1045    async fn constructing_the_real_transport_returns_a_result_rather_than_panicking() {
1046        let _ = SignedTransport::new(Region::UsEast1).await;
1047    }
1048
1049    /// The endpoint, from the model's `endpointPrefix`. `lambda` and not
1050    /// `lambda-microvms` — which is the fact that makes TRAP-6 possible, since this
1051    /// resolves for every AWS region.
1052    #[test]
1053    fn the_endpoint_uses_the_models_lambda_prefix_for_every_region() {
1054        assert_eq!(
1055            endpoint_for(&Region::UsEast1),
1056            "https://lambda.us-east-1.amazonaws.com"
1057        );
1058        assert_eq!(
1059            endpoint_for(&Region::ApNortheast1),
1060            "https://lambda.ap-northeast-1.amazonaws.com"
1061        );
1062        // Including one that does not carry MicroVMs, which is the point.
1063        assert_eq!(
1064            endpoint_for(&Region::unlisted("eu-central-1")),
1065            "https://lambda.eu-central-1.amazonaws.com"
1066        );
1067    }
1068
1069    /// Every path against the model's `http.requestUri`, as literals. Transcribed from
1070    /// `service-2.json` rather than built from a template, because a template shared with
1071    /// the code under test would agree with a wrong template.
1072    #[test]
1073    fn every_path_matches_the_models_request_uri() {
1074        assert_eq!(paths::microvm_images(), "/2025-09-09/microvm-images");
1075        assert_eq!(
1076            paths::microvm_image("img"),
1077            "/2025-09-09/microvm-images/img"
1078        );
1079        assert_eq!(
1080            paths::image_versions("img", None),
1081            "/2025-09-09/microvm-images/img/versions"
1082        );
1083        assert_eq!(
1084            paths::image_version("img", "1"),
1085            "/2025-09-09/microvm-images/img/versions/1"
1086        );
1087        assert_eq!(
1088            paths::image_builds("img", "1", None),
1089            "/2025-09-09/microvm-images/img/versions/1/builds"
1090        );
1091        assert_eq!(
1092            paths::image_build("img", "1", "build-abc"),
1093            "/2025-09-09/microvm-images/img/versions/1/builds/build-abc"
1094        );
1095        assert_eq!(
1096            paths::managed_microvm_images(None),
1097            "/2025-09-09/managed-microvm-images"
1098        );
1099        assert_eq!(
1100            paths::managed_image_versions(
1101                "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1102                None
1103            ),
1104            "/2025-09-09/managed-microvm-images/\
1105             arn%3Aaws%3Alambda%3Aus-east-1%3Aaws%3Amicrovm-image%3Aal2023-1/versions"
1106        );
1107        assert_eq!(paths::microvms(), "/2025-09-09/microvms");
1108        assert_eq!(paths::microvms_list(None), "/2025-09-09/microvms");
1109        assert_eq!(paths::microvm("mvm-1"), "/2025-09-09/microvms/mvm-1");
1110        assert_eq!(
1111            paths::suspend("mvm-1"),
1112            "/2025-09-09/microvms/mvm-1/suspend"
1113        );
1114        assert_eq!(paths::resume("mvm-1"), "/2025-09-09/microvms/mvm-1/resume");
1115        assert_eq!(
1116            paths::auth_token("mvm-1"),
1117            "/2025-09-09/microvms/mvm-1/auth-token"
1118        );
1119        assert_eq!(
1120            paths::shell_auth_token("mvm-1"),
1121            "/2025-09-09/microvms/mvm-1/shell-auth-token"
1122        );
1123    }
1124
1125    /// The listing path carries its query members in the model's spelling, percent-encoded,
1126    /// and a bare listing is the plain collection path with no `?`.
1127    ///
1128    /// An opaque `nextToken` may carry `+`, `/`, or `=`; unencoded, those desynchronise the
1129    /// SigV4 canonical query from the query actually sent, and the rejection reads like bad
1130    /// credentials.
1131    #[test]
1132    fn the_image_listing_path_encodes_its_query_members() {
1133        assert_eq!(
1134            paths::microvm_images_list(None, None),
1135            "/2025-09-09/microvm-images"
1136        );
1137        assert_eq!(
1138            paths::microvm_images_list(Some("my-image"), None),
1139            "/2025-09-09/microvm-images?nameFilter=my-image"
1140        );
1141        assert_eq!(
1142            paths::microvm_images_list(Some("my-image"), Some("a+b/c=")),
1143            "/2025-09-09/microvm-images?nameFilter=my-image&nextToken=a%2Bb%2Fc%3D"
1144        );
1145        assert_eq!(
1146            paths::microvm_images_list(None, Some("token")),
1147            "/2025-09-09/microvm-images?nextToken=token"
1148        );
1149    }
1150
1151    /// The other three paginated listings encode their cursor the same way, and emit no
1152    /// `?` at all without one.
1153    ///
1154    /// Written as literals per listing rather than as a loop over a helper, because the
1155    /// bug this closes is a path that forgets the cursor entirely — and a loop over the
1156    /// helper would pass against three call sites that never pass one.
1157    ///
1158    /// **Falsification** — drop the [`paths::encode_segment`] call from
1159    /// `with_next_token` and every `%2B`/`%2F`/`%3D` assertion below goes red, which is
1160    /// the SigV4 canonical-query desynchronisation this encoder exists to prevent.
1161    #[test]
1162    fn the_other_paginated_listings_encode_their_cursor_too() {
1163        assert_eq!(
1164            paths::image_versions("img", Some("a+b/c=")),
1165            "/2025-09-09/microvm-images/img/versions?nextToken=a%2Bb%2Fc%3D"
1166        );
1167        assert_eq!(
1168            paths::image_builds("img", "1", Some("a+b/c=")),
1169            "/2025-09-09/microvm-images/img/versions/1/builds?nextToken=a%2Bb%2Fc%3D"
1170        );
1171        assert_eq!(
1172            paths::microvms_list(Some("a+b/c=")),
1173            "/2025-09-09/microvms?nextToken=a%2Bb%2Fc%3D"
1174        );
1175
1176        // No cursor means no query string, not an empty one: `?nextToken=` is a member
1177        // present and blank, which is a different request from a member absent.
1178        for path in [
1179            paths::image_versions("img", None),
1180            paths::image_builds("img", "1", None),
1181            paths::microvms_list(None),
1182        ] {
1183            assert!(
1184                !path.contains('?'),
1185                "an absent cursor emits no query: {path}"
1186            );
1187        }
1188    }
1189
1190    /// The version listing's explicit page size: ordered before the cursor, clamped to the
1191    /// model's `1..=50`, and absent from the path when nothing asks for one.
1192    ///
1193    /// The clamp is asserted at both ends because a value outside the bound is a
1194    /// `ValidationException`, and the only caller of this function
1195    /// (`tests/live_pagination.rs`) is trying to *observe* a real cursor — a run that
1196    /// failed on the page size instead would prove nothing about the encoding it came for.
1197    ///
1198    /// **Falsification** — remove the `.clamp(1, 50)` and the `0` and `999` cases go red
1199    /// with `maxResults=0` and `maxResults=999`, both of which the service refuses.
1200    #[test]
1201    fn the_version_listings_explicit_page_size_is_ordered_and_clamped() {
1202        assert_eq!(
1203            paths::image_versions_paged("img", None, Some(1)),
1204            "/2025-09-09/microvm-images/img/versions?maxResults=1"
1205        );
1206        // Both members, and `maxResults` first — the order the query is built in, which is
1207        // asserted so a reordering is a visible change rather than a silent one.
1208        assert_eq!(
1209            paths::image_versions_paged("img", Some("a+b/c="), Some(2)),
1210            "/2025-09-09/microvm-images/img/versions?maxResults=2&nextToken=a%2Bb%2Fc%3D"
1211        );
1212        assert_eq!(
1213            paths::image_versions_paged("img", None, Some(0)),
1214            "/2025-09-09/microvm-images/img/versions?maxResults=1",
1215            "the model's min is 1; a 0 would be refused"
1216        );
1217        assert_eq!(
1218            paths::image_versions_paged("img", None, Some(999)),
1219            "/2025-09-09/microvm-images/img/versions?maxResults=50",
1220            "the model's max is 50; a larger value would be refused"
1221        );
1222
1223        // And the production spelling is unchanged by the new one existing: `image_versions`
1224        // delegates here with no page size, so it must still emit exactly what it did.
1225        assert_eq!(
1226            paths::image_versions_paged("img", None, None),
1227            paths::image_versions("img", None)
1228        );
1229        assert!(
1230            !paths::image_versions("img", Some("t")).contains("maxResults"),
1231            "no production path names a page size: {}",
1232            paths::image_versions("img", Some("t"))
1233        );
1234    }
1235
1236    /// The **managed** routes are a different collection from the account's own, and their
1237    /// cursor is encoded the same way.
1238    ///
1239    /// Asserted as its own case because the plausible mistake is treating the managed listing
1240    /// as a filter on `microvm-images` — it is a separate `requestUri` in the model with a
1241    /// different response shape, so a path built from [`paths::microvm_images`] would address
1242    /// the account's images and answer 200 with the wrong items.
1243    ///
1244    /// **Falsification** — drop the `managed-` prefix from either function and the two
1245    /// literals below go red while every request still succeeds against the real service,
1246    /// which is exactly why these are literals transcribed from the model rather than a
1247    /// shared template.
1248    #[test]
1249    fn the_managed_routes_are_their_own_collection_and_encode_their_cursor() {
1250        assert!(
1251            !paths::managed_microvm_images(None).contains('?'),
1252            "an absent cursor emits no query member"
1253        );
1254        assert_eq!(
1255            paths::managed_microvm_images(Some("a+b/c=")),
1256            "/2025-09-09/managed-microvm-images?nextToken=a%2Bb%2Fc%3D"
1257        );
1258        assert_eq!(
1259            paths::managed_image_versions("al2023-1", Some("a+b/c=")),
1260            "/2025-09-09/managed-microvm-images/al2023-1/versions?nextToken=a%2Bb%2Fc%3D"
1261        );
1262
1263        // Not a filter on the account's own listing: the two collections are different path
1264        // segments, and reading one for the other answers 200 with the wrong items.
1265        assert_ne!(
1266            paths::managed_microvm_images(None),
1267            paths::microvm_images_list(None, None)
1268        );
1269        assert!(
1270            !paths::microvm_images().contains("managed"),
1271            "the account's own collection must not acquire the managed prefix"
1272        );
1273    }
1274
1275    /// A `GET` of one build is the list path plus one encoded segment, and it carries no query
1276    /// member.
1277    ///
1278    /// The `buildId` is percent-encoded even though a real one is a UUID: the model types it
1279    /// `NonBlankString`, so the encoder does not get to assume the shape of a value the
1280    /// service mints.
1281    #[test]
1282    fn one_builds_path_is_the_listing_plus_an_encoded_segment() {
1283        let path = paths::image_build("img", "1", "4a4c5e30-811f-47fa-9893-260ea6a37a8f");
1284        assert_eq!(
1285            path,
1286            "/2025-09-09/microvm-images/img/versions/1/builds/4a4c5e30-811f-47fa-9893-260ea6a37a8f"
1287        );
1288        assert!(!path.contains('?'), "{path}");
1289        assert!(
1290            path.starts_with(&paths::image_builds("img", "1", None)),
1291            "the get path must extend the list path rather than be a second route: {path}"
1292        );
1293        // And an identifier with a separator in it cannot split into extra segments.
1294        assert_eq!(
1295            paths::image_build("img", "1", "a/b"),
1296            "/2025-09-09/microvm-images/img/versions/1/builds/a%2Fb"
1297        );
1298    }
1299
1300    /// A `DELETE` of one version carries no query member, even though its sibling
1301    /// [`paths::image_versions`] now takes a cursor. A version path with `?nextToken=`
1302    /// appended addresses nothing.
1303    #[test]
1304    fn deleting_one_version_carries_no_pagination_cursor() {
1305        let path = paths::image_version("img", "1");
1306        assert_eq!(path, "/2025-09-09/microvm-images/img/versions/1");
1307        assert!(!path.contains('?'), "{path}");
1308        assert!(!path.contains("nextToken"), "{path}");
1309    }
1310
1311    /// A **real** image ARN in a URI parameter is percent-encoded, every colon included.
1312    /// `MicrovmImageIdentifier` is documented as "ARN or ID", so this is the ordinary case
1313    /// rather than an edge one.
1314    ///
1315    /// The literal is the shape the service actually returns, measured 2026-08-15 in
1316    /// us-east-1: `microvm-image:<name>` with a **colon**, which is also the only form the
1317    /// model's `TaggableResource` pattern admits. This test used to hold
1318    /// `microvm-image/img`, and the slash was not a harmless stand-in — `GetMicrovmImage`
1319    /// on that form answers `AccessDeniedException`, because IAM evaluates a malformed ARN
1320    /// as a resource with no matching policy.
1321    #[test]
1322    fn a_real_arn_identifier_is_percent_encoded_colons_and_all() {
1323        let arn = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
1324        let path = paths::microvm_image(arn);
1325        assert_eq!(
1326            path,
1327            "/2025-09-09/microvm-images/arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg"
1328        );
1329        assert!(
1330            !path["/2025-09-09/microvm-images/".len()..].contains(':'),
1331            "an unencoded colon in a path segment is a signature mismatch: {path}"
1332        );
1333    }
1334
1335    /// The encoder still encodes `/`, which is the property that matters even though a real
1336    /// image ARN carries none.
1337    ///
1338    /// Kept as its own case rather than folded into the test above, because the input here
1339    /// is a *shape the service does not use* and labelling it "an ARN" is what let the slash
1340    /// form look load-bearing for twelve fakes. An unencoded slash would split into extra
1341    /// path segments addressing something else, and other identifiers this encoder sees
1342    /// (`imageVersion`, a `nextToken` cursor) genuinely can contain one.
1343    #[test]
1344    fn the_segment_encoder_encodes_slashes_wherever_they_come_from() {
1345        assert_eq!(paths::encode_segment("a/b"), "a%2Fb");
1346        let path = paths::microvm_image("has/a/slash");
1347        assert_eq!(path, "/2025-09-09/microvm-images/has%2Fa%2Fslash");
1348        assert!(
1349            !path["/2025-09-09/microvm-images/".len()..].contains('/'),
1350            "an encoded identifier contributes no path separators: {path}"
1351        );
1352    }
1353
1354    /// The API version in the path is the constant the drift gate checks, not a second
1355    /// literal. Two copies of `2025-09-09` are two things to update, and the one that gets
1356    /// missed is the one nothing compiles against.
1357    #[test]
1358    fn the_path_version_is_the_pinned_model_version() {
1359        assert_eq!(API_PATH_VERSION, crate::constants::MODEL_API_VERSION);
1360        assert!(paths::microvms().starts_with("/2025-09-09/"));
1361    }
1362
1363    /// The signing name is `lambda`, from the model's `signingName`. A signature computed
1364    /// for `lambda-microvms` is rejected in a way that reads like bad credentials.
1365    #[test]
1366    fn the_signing_name_is_lambda_not_lambda_microvms() {
1367        assert_eq!(SIGNING_NAME, "lambda");
1368    }
1369
1370    /// Signing puts an `authorization` header on the request naming the region and the
1371    /// signing name, and an `x-amz-security-token` when the credentials are temporary.
1372    ///
1373    /// Static credentials rather than a resolved chain, so this runs with no AWS
1374    /// configuration and no network — the signature is a pure function of the inputs.
1375    #[test]
1376    fn signing_names_the_region_and_the_service_in_the_credential_scope() {
1377        let credentials = aws_credential_types::Credentials::new(
1378            "AKIDEXAMPLE",
1379            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1380            Some("session-token".to_string()),
1381            None,
1382            "test",
1383        );
1384        let mut request = http::Request::builder()
1385            .method("POST")
1386            .uri("https://lambda.eu-west-1.amazonaws.com/2025-09-09/microvms")
1387            .header("content-type", "application/json")
1388            .body(b"{}".to_vec())
1389            .expect("builds");
1390
1391        sign_in_place(&mut request, &credentials, &Region::EuWest1).expect("signs");
1392
1393        let headers = request.headers();
1394        let authorization = headers
1395            .get("authorization")
1396            .expect("a signature was applied")
1397            .to_str()
1398            .expect("ascii");
1399        assert!(
1400            authorization.starts_with("AWS4-HMAC-SHA256 "),
1401            "{authorization}"
1402        );
1403        assert!(
1404            authorization.contains("/eu-west-1/lambda/aws4_request"),
1405            "the credential scope must name the request region and the signing name: \
1406             {authorization}"
1407        );
1408        assert!(
1409            headers.contains_key("x-amz-date"),
1410            "a signed date is required"
1411        );
1412        assert!(
1413            headers.contains_key("x-amz-security-token"),
1414            "temporary credentials carry a session token in the canonical request"
1415        );
1416    }
1417
1418    /// A `GET` with no body still gets signed, with the empty-payload SHA256 —
1419    /// `SignableBody::Bytes(&[])` rather than `UnsignedPayload`, which non-S3 services
1420    /// reject.
1421    #[test]
1422    fn a_bodyless_get_is_signed_rather_than_sent_unsigned() {
1423        let credentials =
1424            aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
1425        let mut request = http::Request::builder()
1426            .method("GET")
1427            .uri("https://lambda.us-east-1.amazonaws.com/2025-09-09/microvms/mvm-1")
1428            .body(Vec::new())
1429            .expect("builds");
1430
1431        sign_in_place(&mut request, &credentials, &Region::UsEast1).expect("signs");
1432        assert!(request.headers().contains_key("authorization"));
1433        assert!(
1434            !request.headers().contains_key("x-amz-security-token"),
1435            "static credentials carry no session token"
1436        );
1437    }
1438
1439    /// TRAP-6's diagnostic, on the exact response shape the trap produces: 403 with a
1440    /// null message. The message has to name the region cause *and* say the field was
1441    /// null, because "AccessDeniedException" alone is what sends a reader to the IAM
1442    /// console.
1443    #[test]
1444    fn a_null_message_access_denied_names_the_region_trap() {
1445        let reply = Reply {
1446            status: 403,
1447            body: br#"{"message": null}"#.to_vec(),
1448        };
1449        let error = classify_failure("ListMicrovms", &reply);
1450        assert_eq!(error.kind(), ErrorKind::Credentials);
1451        let message = error.to_string();
1452        assert!(message.contains("null"), "{message}");
1453        assert!(message.contains("region"), "{message}");
1454        assert!(message.contains("does not carry MicroVMs"), "{message}");
1455        assert!(
1456            message.contains("Check the region before the policy"),
1457            "the remedy has to be the region, not the policy: {message}"
1458        );
1459    }
1460
1461    /// A 403 *with* a message is a genuine denial and says so, so the two are not
1462    /// conflated. A classifier that named the region trap on every 403 would be as
1463    /// misleading in the other direction.
1464    #[test]
1465    fn a_403_with_a_message_is_reported_as_a_genuine_denial() {
1466        let reply = Reply {
1467            status: 403,
1468            body: br#"{"message": "User: arn:aws:iam::1:user/u is not authorized"}"#.to_vec(),
1469        };
1470        let error = classify_failure("RunMicrovm", &reply);
1471        assert_eq!(error.kind(), ErrorKind::Credentials);
1472        let message = error.to_string();
1473        assert!(message.contains("not authorized"), "{message}");
1474        assert!(
1475            message.contains("genuine denial"),
1476            "must distinguish itself from the null-message case: {message}"
1477        );
1478        assert!(
1479            !message.contains("does not carry MicroVMs"),
1480            "must not blame the region when the service named a principal: {message}"
1481        );
1482    }
1483
1484    /// Only the throttle and the 5xx family are retryable. A retried 409 would spin
1485    /// against an image in CREATING, and a retried 400 would send the same rejected value
1486    /// five more times.
1487    #[test]
1488    fn only_throttles_and_server_faults_are_retryable() {
1489        let cases = [
1490            (400, false),
1491            (402, false),
1492            (403, false),
1493            (404, false),
1494            (409, false),
1495            (429, true),
1496            (500, true),
1497            (502, true),
1498            (503, true),
1499        ];
1500        for (status, retryable) in cases {
1501            let reply = Reply {
1502                status,
1503                body: br#"{"message": "detail"}"#.to_vec(),
1504            };
1505            let error = classify_failure("GetMicrovm", &reply);
1506            assert_eq!(error.retryable(), retryable, "HTTP {status}");
1507        }
1508    }
1509
1510    /// A control-plane failure carries **no** `WireKind`: nothing reached the in-VM
1511    /// daemon, so there is no daemon-chosen status to report and the CLI's `data.kind`
1512    /// must be absent rather than invented.
1513    #[test]
1514    fn a_control_plane_failure_carries_no_daemon_wire_kind() {
1515        for status in [400, 403, 404, 409, 429, 500] {
1516            let reply = Reply {
1517                status,
1518                body: Vec::new(),
1519            };
1520            assert_eq!(
1521                classify_failure("GetMicrovm", &reply).wire_kind(),
1522                None,
1523                "HTTP {status} never reached the daemon"
1524            );
1525        }
1526    }
1527
1528    /// An unparseable error body still classifies by status rather than failing. A service
1529    /// answering an HTML error page or a truncated body must not turn a 429 into an
1530    /// unexpected error, which would stop the retry.
1531    #[test]
1532    fn an_unparseable_error_body_still_classifies_by_status() {
1533        let reply = Reply {
1534            status: 429,
1535            body: b"<html>Too Many Requests</html>".to_vec(),
1536        };
1537        let error = classify_failure("RunMicrovm", &reply);
1538        assert!(error.retryable(), "a throttle is a throttle");
1539        assert!(error.to_string().contains("no message"), "{error}");
1540    }
1541
1542    /// A body-parse failure names the operation and shows the body, because a rest-json
1543    /// parse error with the text withheld cannot distinguish a renamed member from a
1544    /// truncated response.
1545    #[test]
1546    fn a_response_that_does_not_parse_reports_the_body() {
1547        let reply = Reply {
1548            status: 200,
1549            body: br#"{"microvmId": 42}"#.to_vec(),
1550        };
1551        let error = reply
1552            .json::<crate::control::ops::MicrovmResponseWire>("GetMicrovm")
1553            .expect_err("microvmId is a string shape");
1554        let message = error.to_string();
1555        assert!(message.contains("GetMicrovm"), "{message}");
1556        assert!(message.contains("microvmId"), "{message}");
1557        assert_eq!(error.kind(), ErrorKind::Platform);
1558    }
1559
1560    /// A `POST` with no body members sends `{}` rather than nothing: an empty POST and a
1561    /// POST of `{}` are different canonical requests, and rest-json services answer the
1562    /// former inconsistently.
1563    #[test]
1564    fn a_bodyless_post_sends_an_empty_json_object() {
1565        let call = Call::post_empty("SuspendMicrovm", paths::suspend("mvm-1"));
1566        assert_eq!(call.body.as_deref(), Some(&b"{}"[..]));
1567        assert_eq!(call.method, Method::Post);
1568    }
1569
1570    /// A `GET` and a `DELETE` carry no body at all, and the signed empty payload is what
1571    /// `body_bytes` yields.
1572    #[test]
1573    fn a_get_and_a_delete_carry_no_body() {
1574        let get = Call::get("GetMicrovm", paths::microvm("mvm-1"));
1575        assert_eq!(get.body, None);
1576        assert_eq!(get.body_bytes(), b"");
1577
1578        let delete = Call::delete("TerminateMicrovm", paths::microvm("mvm-1"));
1579        assert_eq!(delete.body, None);
1580        assert_eq!(delete.method, Method::Delete);
1581    }
1582
1583    /// The methods, spelled as the canonical request needs them: uppercase.
1584    #[test]
1585    fn the_methods_are_the_ones_the_model_uses_spelled_uppercase() {
1586        assert_eq!(Method::Get.as_str(), "GET");
1587        assert_eq!(Method::Post.as_str(), "POST");
1588        assert_eq!(Method::Patch.as_str(), "PATCH");
1589        assert_eq!(Method::Delete.as_str(), "DELETE");
1590    }
1591
1592    /// A `PATCH` carries its serialized body and is **not** a `POST`.
1593    ///
1594    /// The distinction is load-bearing rather than cosmetic: `UpdateMicrovmImageVersion` is the
1595    /// model's only `PATCH`, and a `POST` to the version path is a route the service does not
1596    /// declare — so the failure would be a 404 that reads as a missing version rather than as a
1597    /// wrong method. The body assertion is here too, because a `PATCH` whose body was dropped
1598    /// would be a request with no `status` member at all.
1599    ///
1600    /// **Falsification** — set `method: Method::Post` in `patch_json` and the method assertion
1601    /// goes red; the path and body assertions would still pass, which is why the method is
1602    /// asserted separately.
1603    #[test]
1604    fn a_patch_carries_its_body_and_is_not_a_post() {
1605        let call = Call::patch_json(
1606            "UpdateMicrovmImageVersion",
1607            paths::image_version("img", "2.0"),
1608            &crate::control::ops::UpdateImageVersionWire {
1609                status: crate::control::ops::VersionStatus::Inactive,
1610            },
1611        )
1612        .expect("serialises");
1613
1614        assert_eq!(call.method, Method::Patch);
1615        assert_ne!(
1616            call.method,
1617            Method::Post,
1618            "a POST to the version path is a route the model does not declare"
1619        );
1620        assert_eq!(call.path, "/2025-09-09/microvm-images/img/versions/2.0");
1621        assert_eq!(
1622            call.body.as_deref(),
1623            Some(&br#"{"status":"INACTIVE"}"#[..]),
1624            "the one member the request has must reach the body"
1625        );
1626        assert_eq!(call.operation, "UpdateMicrovmImageVersion");
1627    }
1628
1629    /// A `PATCH` gets signed like every other method, with the empty-payload rule not applying
1630    /// because it has a body.
1631    ///
1632    /// Worth its own case because the signing path reads the method as a string for the
1633    /// canonical request: a method the enum spells and the signer does not is a signature over
1634    /// a different request than the one sent, which reads as bad credentials.
1635    #[test]
1636    fn a_patch_is_signed_with_its_method_in_the_canonical_request() {
1637        let credentials =
1638            aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
1639        let mut request = http::Request::builder()
1640            .method(Method::Patch.as_str())
1641            .uri(
1642                "https://lambda.us-east-1.amazonaws.com/2025-09-09/microvm-images/img/versions/2.0",
1643            )
1644            .header("content-type", "application/json")
1645            .body(br#"{"status":"INACTIVE"}"#.to_vec())
1646            .expect("builds");
1647
1648        sign_in_place(&mut request, &credentials, &Region::UsEast1).expect("signs");
1649        assert_eq!(request.method().as_str(), "PATCH");
1650        assert!(request.headers().contains_key("authorization"));
1651    }
1652}