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