microvms_core/control/microvm.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Launch, the launch wait, the lifecycle calls, and the proxy token (TRAP-5, TRAP-8).
3//!
4//! # TRAP-5: the payload ceiling is 4096 bytes, inclusive
5//!
6//! [`RunHookPayload`] cannot hold an over-ceiling value, so the check happens when the
7//! payload is *made* rather than when the request is sent — which means every path into
8//! `RunMicrovm` is closed by one guard instead of each needing its own.
9//!
10//! The number is worth stating precisely because the project got it wrong in the dangerous
11//! direction. `docs/STRATEGY.md` and `docs/TRUST.md` claimed 16 KB — and so does the
12//! service model's own **documentation** string for `runHookPayload`, which reads "Maximum:
13//! 16,384 bytes" while its shape `RunMicrovmRequestRunHookPayloadString` says `max: 4096`.
14//! So a reader who checks the docs rather than the shape reproduces the bug. 4x wrong in
15//! the direction that tells a caller four times as much secret material fits as actually
16//! does. Bracketed 2026-08-07 by calling `RunMicrovm` with a deliberately bogus
17//! `imageIdentifier` so nothing could be created or billed: **4096 passes, 4097 fails.**
18//!
19//! Measured in UTF-8 **bytes**, not characters. A payload counted by character length
20//! passes while the same value with one multi-byte character in it does not.
21//!
22//! # TRAP-5's other half: why the payload at all
23//!
24//! It is the only per-VM secret channel the platform offers, and it is what keeps the agent
25//! token out of the shared image snapshot. That is safe because the platform forwards no
26//! external traffic until the run hook returns 200, so a per-VM secret delivered at launch
27//! wins the first-writer race through the endpoint.
28//!
29//! The daemon reads it one JSON parse deeper than expected: the platform **wraps** the
30//! string, so the hook body is `{"runHookPayload": "{\"agent_token\": \"...\"}"}`.
31//!
32//! # TRAP-8: a terminal state before RUNNING
33//!
34//! A VM that reaches a terminal state before RUNNING died during startup, and for a
35//! hook-serving daemon that almost always means a lifecycle hook failed. Polling through it
36//! wastes minutes and then reports a connection error that hides the cause — and by then
37//! the VM is gone, so `stateReason` is the only evidence left. [`ControlPlane::wait_for_running`]
38//! fails fast and attaches both the state and the reason.
39//!
40//! # TRAP-11, again: the shell mint is a separate surface, not a sibling of exec's
41//!
42//! [`ControlPlane::mint_auth_token`] calls `CreateMicrovmAuthToken` and is what the exec
43//! path uses. [`ControlPlane::mint_shell_auth_token`] calls `CreateMicrovmShellAuthToken`
44//! and exists for exactly one caller: `microvm shell`, the interactive PTY (issue #69).
45//! Nothing on the exec or session path reaches it — the lifecycle test below drives
46//! everything exec can do and asserts zero shell calls were emitted. See [`super`].
47
48use super::transport::{Call, paths, send_with_retry};
49use super::{ControlPlane, RunMicrovmRequest, WaitOpts, ops, timed_out, token};
50use crate::error::{Error, ErrorKind};
51
52/// The proxy token's auth header, which is also the key in the `authToken` map.
53pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
54
55/// The proxy port header.
56///
57/// Sent on **every** endpoint request alongside the auth header: `X-aws-proxy-auth` without
58/// `X-aws-proxy-port` is rejected in a way that reads like a bad token, which is a long
59/// detour for a missing header.
60pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
61
62/// The service ceiling on a proxy token's life, in minutes.
63pub const MAX_TOKEN_MINUTES: u32 = 60;
64
65/// A `runHookPayload` that is known to fit the service ceiling.
66///
67/// The type is the guard: there is no way to build one over 4096 bytes, so no call site has
68/// to remember to check. See the module docs for the measurement and for why the service
69/// model's own documentation string disagrees with its shape.
70#[derive(Clone, Eq, PartialEq)]
71pub struct RunHookPayload(String);
72
73/// Prints the size and nothing else.
74///
75/// The payload is the platform's only per-VM secret channel — its whole purpose is to carry
76/// the agent token — so a derived `Debug` would print that token into every log line that
77/// formats one, and into every line that formats the [`RunMicrovmRequest`] holding it.
78/// [`RunMicrovmRequest`] keeps its derive: with this impl in place, the derived one prints
79/// `RunHookPayload(<N bytes>)` for that field, so the safety is inherited rather than
80/// restated. The length is kept because it is the number every TRAP-5 diagnosis needs.
81impl std::fmt::Debug for RunHookPayload {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "RunHookPayload(<{} bytes>)", self.0.len())
84 }
85}
86
87impl RunHookPayload {
88 /// The payload carrying an agent token, as the daemon expects to find it.
89 ///
90 /// `{"agent_token": "<token>"}` — and the platform wraps *this whole string* as the
91 /// value of `runHookPayload`, which is the extra parse the daemon does.
92 ///
93 /// Checked even though this function builds the JSON itself, because the token is
94 /// caller-supplied: someone passing a JWT or a signed blob rather than a bearer token is
95 /// exactly who this catches.
96 pub fn for_agent_token(agent_token: &str) -> Result<Self, Error> {
97 Self::for_launch(agent_token, &std::collections::HashMap::new())
98 }
99
100 /// The payload carrying an agent token and a launch environment.
101 ///
102 /// `{"agent_token": "<token>", "env": {...}}`, and the platform wraps that whole
103 /// string as the value of `runHookPayload` — the extra parse the daemon does.
104 /// The daemon applies `env` as the base environment of every subsequent exec,
105 /// under the per-request `env`.
106 ///
107 /// **This is where the 4096-byte refusal fires for a launch env.** The check is
108 /// on the serialized payload rather than on the map, because the ceiling is on
109 /// the string: JSON escaping, the key names, and the punctuation all count, so a
110 /// caller who measured their own values would measure the wrong thing. And it is
111 /// local rather than left to the service for the same reason
112 /// [`super::super::control::image`]'s `require_workdir` and
113 /// `require_matching_from` are local: the service's answer arrives as a
114 /// `ValidationException` on a member the caller did not know they were filling,
115 /// after the call, and botocore does not enforce it client-side at all (measured;
116 /// `docs/PLATFORM.md`). `env` is what makes this reachable in practice — one
117 /// bearer token has always fit with room to spare, and a map of credentials does
118 /// not.
119 ///
120 /// The env is omitted from the JSON entirely when it is empty, so a caller who
121 /// passes no launch env produces byte-for-byte the payload
122 /// [`RunHookPayload::for_agent_token`] always produced. A pinned daemon that has
123 /// never heard of `env` therefore sees no change, and the two constructors do not
124 /// have two different budgets.
125 pub fn for_launch(
126 agent_token: &str,
127 env: &std::collections::HashMap<String, String>,
128 ) -> Result<Self, Error> {
129 Self::for_launch_with_identity(agent_token, env, None)
130 }
131
132 /// [`RunHookPayload::for_launch`], optionally carrying the tunnel identity material.
133 ///
134 /// The two identity fields are the VM's own seed and the launching host's public key,
135 /// under the keys `protocol::identity` declares — the daemon pins one and derives from
136 /// the other, which is what makes `microvm tunnel --verify-identity` able to prove the
137 /// far end without trusting the endpoint proxy. `None` produces byte-for-byte the
138 /// payload the other constructors always built, so a caller who never asks for identity
139 /// cannot be affected by the field existing (the same compatibility rule `env` follows).
140 ///
141 /// The material costs [`protocol::identity::IDENTITY_PAYLOAD_BYTES`] of the shared
142 /// 4096-byte budget — two 44-character base64 values plus JSON framing — and the ceiling
143 /// check below covers it like everything else in the payload.
144 pub fn for_launch_with_identity(
145 agent_token: &str,
146 env: &std::collections::HashMap<String, String>,
147 identity: Option<&crate::identity::LaunchIdentity>,
148 ) -> Result<Self, Error> {
149 let mut payload = if env.is_empty() {
150 serde_json::json!({ "agent_token": agent_token })
151 } else {
152 serde_json::json!({ "agent_token": agent_token, "env": env })
153 };
154 if let Some(identity) = identity {
155 let object = payload
156 .as_object_mut()
157 .expect("the payload is built as an object two lines up");
158 object.insert(
159 protocol::identity::SEED_KEY.to_string(),
160 serde_json::Value::String(identity.seed_field()),
161 );
162 object.insert(
163 protocol::identity::HOST_PUBLIC_KEY_KEY.to_string(),
164 serde_json::Value::String(identity.host_public_field()),
165 );
166 }
167 let text = serde_json::to_string(&payload).map_err(|error| {
168 Error::new(
169 ErrorKind::Unexpected,
170 format!("could not serialize the run-hook payload: {error}"),
171 )
172 })?;
173 // The length is read before the value moves into `new`, so the refusal below can
174 // account for it without rebuilding the payload.
175 let total = text.len();
176 Self::new(text).map_err(|error| {
177 if env.is_empty() {
178 return error;
179 }
180 // The generic message names the byte count and the ceiling, which is the
181 // whole diagnosis for a token. For a launch env it is half of one: the
182 // caller wants to know how much of the budget their env is, because the
183 // fix is to move a value out of it rather than to shorten the token.
184 Error::invalid_arg(format!(
185 "{error} The launch env contributed {} of those bytes across {} variable(s); \
186 the payload's other {} are the token and the JSON framing. A launch env is \
187 for small values a workload needs at startup — move credential-scale \
188 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
189 assumes.",
190 env_contribution(agent_token, env),
191 env.len(),
192 total.saturating_sub(env_contribution(agent_token, env)),
193 ))
194 })
195 }
196
197 /// An arbitrary payload, if it fits.
198 ///
199 /// Public because the run-hook body is the caller's channel and this crate does not own
200 /// what goes in it; the ceiling is the only thing it enforces.
201 pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
202 let payload = payload.into();
203 // Bytes, not characters: the ceiling is on the serialized string, so a payload
204 // measured by character count passes while the same value with one multi-byte
205 // character in it does not.
206 let size = payload.len();
207 if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
208 return Ok(Self(payload));
209 }
210 Err(Error::invalid_arg(format!(
211 "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
212 inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
213 channel the platform offers — one bearer token fits, a cloud credential set does \
214 not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
215 documentation string for this member all claim 16 KB, which is wrong by 4x in the \
216 dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
217 authority and it states max 4096.",
218 crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
219 crate::constants::MODEL_API_VERSION,
220 )))
221 }
222
223 /// The payload's bytes as the wire will carry them.
224 pub fn as_str(&self) -> &str {
225 &self.0
226 }
227
228 /// The size in bytes, which is what the ceiling measures.
229 pub fn len(&self) -> usize {
230 self.0.len()
231 }
232
233 /// Whether the payload is empty. The model's minimum is 0, so an empty payload is legal.
234 pub fn is_empty(&self) -> bool {
235 self.0.is_empty()
236 }
237}
238
239/// How many of a launch payload's bytes the env accounts for.
240///
241/// Measured by difference against the token-only payload rather than by summing key and
242/// value lengths, because the ceiling is on the *serialized* string: the `"env":{}`
243/// wrapper, the quotes, the commas, and every backslash JSON escaping adds all count
244/// against the budget, and a figure that summed the raw values would understate exactly
245/// the payloads that are near the limit.
246///
247/// Only ever called on the refusal path, where the payload has already been built once.
248fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
249 let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
250 let without = serde_json::json!({ "agent_token": agent_token }).to_string();
251 with.len().saturating_sub(without.len())
252}
253
254/// A launched MicroVM, as the service last described it.
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub struct Microvm {
257 pub id: String,
258 pub state: String,
259 /// The proxy endpoint. Present from the launch response onward.
260 pub endpoint: String,
261 pub image_arn: String,
262 pub image_version: String,
263 /// Why the VM is in this state, when the service said. The absence is information —
264 /// TRAP-8's message distinguishes "no stateReason" from an empty one.
265 pub state_reason: Option<String>,
266 /// The idle policy the service reports the VM is running under.
267 ///
268 /// Carried rather than dropped because it is the platform's own account of the window
269 /// that will suspend and then terminate this VM, and it was measured coming back on a
270 /// real `GetMicrovm` — see [`ops::IdlePolicy`], which used to claim it does not. The
271 /// client's request is what *asked* for a window; this is what the service says it got,
272 /// and a caller diagnosing a VM that vanished earlier than expected needs the second.
273 ///
274 /// `Option` because the model does not mark the member required on either response
275 /// shape, so an absent one must parse rather than fail.
276 pub idle_policy: Option<ops::IdlePolicy>,
277}
278
279impl Microvm {
280 /// The reason the service gave, or a phrase saying it gave none.
281 ///
282 /// A phrase rather than an empty string, because "the service said nothing" and "the
283 /// service said nothing useful" are different diagnoses and a caller reading a message
284 /// has to be able to tell them apart.
285 fn reason(&self) -> &str {
286 self.state_reason.as_deref().unwrap_or("no stateReason")
287 }
288}
289
290impl From<ops::MicrovmResponseWire> for Microvm {
291 fn from(wire: ops::MicrovmResponseWire) -> Self {
292 Self {
293 id: wire.microvm_id,
294 state: wire.state,
295 endpoint: wire.endpoint,
296 image_arn: wire.image_arn,
297 image_version: wire.image_version,
298 state_reason: wire.state_reason,
299 idle_policy: wire.idle_policy,
300 }
301 }
302}
303
304/// The minted proxy token, as a header map (TRAP-7).
305///
306/// # Why a map rather than a string
307///
308/// Because that is what the service answers: `authToken` is a `TokenParts` map, and the
309/// value at `X-aws-proxy-auth` is the header value. A client that treated the response as a
310/// string would send a stringified map and get a rejection that reads like a bad token.
311///
312/// [`ProxyToken::headers`] always emits **both** headers, which is the other half of the
313/// same trap: `X-aws-proxy-auth` without `X-aws-proxy-port` is rejected the same
314/// indistinguishable way.
315#[derive(Clone, Eq, PartialEq)]
316pub struct ProxyToken {
317 value: String,
318 port: u16,
319}
320
321/// Names the headers and the port, never the token value.
322///
323/// A minted proxy token is a credential, and a derived `Debug` would print it into every
324/// log line that formats a [`ProxyToken`], a [`RunMicrovmRequest`] holding one, or an error
325/// chain containing either. The sibling type in [`crate::session::proxy`] made this choice
326/// first; this is the same rule on the control-plane side of the conversion.
327impl std::fmt::Debug for ProxyToken {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 f.debug_struct("ProxyToken")
330 .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
331 .field("port", &self.port)
332 .finish()
333 }
334}
335
336impl ProxyToken {
337 /// Reads the token out of the service's `authToken` map.
338 ///
339 /// A missing `X-aws-proxy-auth` key is [`ErrorKind::Retryable`] via
340 /// [`crate::error::WireKind::AuthTokenMint`]: minting is inside the request retry path
341 /// (TRAP-9), and a throttle at minute 30 of a long run must not kill a trial that is
342 /// otherwise healthy.
343 pub fn from_map(
344 map: &std::collections::BTreeMap<String, String>,
345 port: u16,
346 ) -> Result<Self, Error> {
347 Self::from_operation_map("CreateMicrovmAuthToken", map, port)
348 }
349
350 /// [`ProxyToken::from_map`], naming the operation whose response the map came from.
351 ///
352 /// The shell mint reads the same `TokenParts` shape out of
353 /// `CreateMicrovmShellAuthToken`, and a missing-key failure that blamed the ordinary
354 /// mint would send the reader to the wrong operation's response.
355 pub fn from_operation_map(
356 operation: &str,
357 map: &std::collections::BTreeMap<String, String>,
358 port: u16,
359 ) -> Result<Self, Error> {
360 let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
361 Error::wire(
362 crate::error::WireKind::AuthTokenMint,
363 format!(
364 "the {operation} response carried no {PROXY_AUTH_HEADER} key. The \
365 authToken member is a map, not a string, and that key is the header value \
366 (keys present: {:?}).",
367 map.keys().collect::<Vec<_>>()
368 ),
369 )
370 })?;
371 Ok(Self {
372 value: value.clone(),
373 port,
374 })
375 }
376
377 /// Both headers every endpoint request needs.
378 ///
379 /// Both, always. See the type docs: sending only the auth header is rejected in a way
380 /// that reads like a bad token.
381 pub fn headers(&self) -> [(&'static str, String); 2] {
382 [
383 (PROXY_AUTH_HEADER, self.value.clone()),
384 (PROXY_PORT_HEADER, self.port.to_string()),
385 ]
386 }
387
388 /// The raw token value.
389 pub fn value(&self) -> &str {
390 &self.value
391 }
392
393 /// The port the token was minted for.
394 pub fn port(&self) -> u16 {
395 self.port
396 }
397}
398
399impl ControlPlane {
400 /// Launches a MicroVM.
401 ///
402 /// TRAP-5 is already closed by [`RunHookPayload`]'s type; the duration range is checked
403 /// here, and the connectors are derived from intents (TRAP-4). The `clientToken` is
404 /// minted from a label (TRAP-1).
405 pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
406 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
407 super::require_duration_in_range(request.max_duration_sec)?;
408 // `min: 60` on `IdlePolicyMaxIdleDurationSecondsInteger`. There used to be no guard for
409 // this on the grounds that botocore enforces `min` locally — true of the deleted Python
410 // client and never of this one. See `super::require_idle_duration`.
411 super::require_idle_duration(request.max_idle_sec)?;
412 // Checked before the wire because a pinned launch is what a rollback does, and a
413 // `ValidationException` on the member rather than on the version is the least useful
414 // failure available at that moment. See `super::require_valid_version`.
415 if let Some(version) = request.image_version.as_deref() {
416 super::require_valid_version("imageVersion", version)?;
417 }
418 // `RoleArn` — optional in the model, and every real launch has one. A malformed value
419 // here is a `ValidationException` on a member the caller may not know is being filled
420 // from their infra config, so the refusal names it.
421 if let Some(role) = request.execution_role_arn.as_deref() {
422 super::require_valid_role_arn("executionRoleArn", role)?;
423 }
424
425 // `ALL_INGRESS` cannot be combined with any other ingress connector, and the
426 // platform says so only at token-mint time: `RunMicrovm` accepts the invalid
427 // set, the VM reaches RUNNING, and it bills until something asks for a shell
428 // token (`docs/PLATFORM.md`, measured 2026-08-15). Refused here instead, before
429 // anything launches or bills. The pair that works is `[HTTP_INGRESS,
430 // SHELL_INGRESS]`.
431 let has_all_ingress = request
432 .connectors
433 .contains(&super::ConnectorIntent::AllIngress);
434 let has_finer_ingress = request.connectors.iter().any(|intent| {
435 matches!(
436 intent,
437 super::ConnectorIntent::HttpIngress | super::ConnectorIntent::ShellIngress
438 )
439 });
440 if has_all_ingress && has_finer_ingress {
441 return Err(Error::invalid_arg(
442 "ALL_INGRESS cannot be combined with other ingress connectors; use \
443 HTTP_INGRESS and/or SHELL_INGRESS instead. The platform accepts the \
444 combination at launch and rejects it only when a shell token is minted, \
445 after the VM has launched and billed — so it is refused here.",
446 ));
447 }
448
449 // The other half of the same measured constraint: the pair that launches and
450 // mints a shell token is `[HTTP_INGRESS, SHELL_INGRESS]`. A shell-only ingress
451 // set has no measured success path, and the platform accepts an invalid set at
452 // launch — so it too is refused here, before anything launches or bills.
453 let has_shell_ingress = request
454 .connectors
455 .contains(&super::ConnectorIntent::ShellIngress);
456 let has_http_ingress = request
457 .connectors
458 .contains(&super::ConnectorIntent::HttpIngress);
459 if has_shell_ingress && !has_http_ingress {
460 return Err(Error::invalid_arg(
461 "SHELL_INGRESS was requested without HTTP_INGRESS. The measured pair that \
462 launches and mints a shell token is [HTTP_INGRESS, SHELL_INGRESS] \
463 (docs/PLATFORM.md); the platform accepts an invalid ingress set at launch \
464 and the VM runs and bills before any failure appears — so it is refused \
465 here.",
466 ));
467 }
468
469 // Ingress and egress go in separate members, so they are split by intent rather
470 // than concatenated into one list.
471 let ingress: Vec<String> = request
472 .connectors
473 .iter()
474 .filter(|intent| {
475 matches!(
476 intent,
477 super::ConnectorIntent::AllIngress
478 | super::ConnectorIntent::HttpIngress
479 | super::ConnectorIntent::ShellIngress
480 )
481 })
482 .map(|intent| intent.arn(&self.region))
483 .collect();
484 let egress: Vec<String> = request
485 .connectors
486 .iter()
487 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
488 .map(|intent| intent.arn(&self.region))
489 .collect();
490
491 if ingress.len() + egress.len() > crate::constants::MAX_NETWORK_CONNECTORS {
492 return Err(Error::invalid_arg(format!(
493 "{} network connectors were requested, over the NetworkConnectorList ceiling of \
494 {} (service model {}).",
495 ingress.len() + egress.len(),
496 crate::constants::MAX_NETWORK_CONNECTORS,
497 crate::constants::MODEL_API_VERSION,
498 )));
499 }
500
501 let wire = ops::RunMicrovmWire {
502 image_identifier: request.image_identifier.clone(),
503 image_version: request.image_version.clone(),
504 execution_role_arn: request.execution_role_arn.clone(),
505 ingress_network_connectors: ingress,
506 // Absent rather than empty: omitting egress is how you get no outbound network.
507 egress_network_connectors: (!egress.is_empty()).then_some(egress),
508 idle_policy: ops::IdlePolicy {
509 max_idle_duration_seconds: request.max_idle_sec,
510 suspended_duration_seconds: request.suspended_sec,
511 auto_resume_enabled: request.auto_resume,
512 },
513 maximum_duration_in_seconds: request.max_duration_sec,
514 run_hook_payload: request.run_hook_payload.as_str().to_string(),
515 client_token: token::run_token(
516 request
517 .token_scope
518 .as_deref()
519 .unwrap_or(&request.image_identifier),
520 ),
521 };
522
523 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
524 let reply = send_with_retry(self.transport(), call).await?;
525 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
526 Ok(launched.into())
527 }
528
529 /// Polls to RUNNING, failing fast on a terminal state (TRAP-8).
530 ///
531 /// The fast failure is the whole value. A VM that reaches a terminal state before
532 /// RUNNING died during startup; polling through it wastes minutes and then reports a
533 /// connection error that hides the cause, and by then the VM is gone so `stateReason` is
534 /// the only evidence left.
535 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
536 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
537 .await
538 }
539
540 /// Polls until the VM reaches one of `wanted`, failing fast on any of `fail_on`.
541 ///
542 /// # Why `fail_on` is a parameter rather than always the terminal set
543 ///
544 /// Because `suspend` **wants** SUSPENDED and tolerates TERMINATED: a VM that dies while
545 /// suspending is a state to report, not an exception to raise out of the middle of a
546 /// teardown. The resume path passes the *dead* states instead, because a VM the idle
547 /// policy terminated during suspension never reaches RUNNING — and waiting only for
548 /// RUNNING there burns the full timeout and then reports "never reached RUNNING", a
549 /// timeout message hiding a cause the service had already stated.
550 pub async fn wait_for_state(
551 &self,
552 id: &str,
553 wanted: &[&str],
554 fail_on: &[&str],
555 opts: WaitOpts,
556 ) -> Result<Microvm, Error> {
557 let started = self.clock().elapsed();
558 loop {
559 let got = self.get_microvm(id).await?;
560
561 if wanted.contains(&got.state.as_str()) {
562 return Ok(got);
563 }
564 if fail_on.contains(&got.state.as_str()) {
565 return Err(self.reached_terminal_state(&got, wanted));
566 }
567
568 let elapsed = self.clock().elapsed().saturating_sub(started);
569 if elapsed >= opts.timeout {
570 return Err(timed_out(
571 &format!(
572 "microvm {id} never reached {wanted:?} (last state {})",
573 got.state
574 ),
575 elapsed,
576 ));
577 }
578 self.clock().sleep(opts.poll_interval).await;
579 }
580 }
581
582 /// TRAP-8's error: the state **and** the reason, both in the message.
583 ///
584 /// Both because either alone is unactionable. The state says the VM is gone; the reason
585 /// says why, and it is the only evidence that survives the VM.
586 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
587 Error::new(
588 ErrorKind::LaunchDied,
589 format!(
590 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
591 before RUNNING died during startup, and for a hook-serving daemon that almost \
592 always means a lifecycle hook failed — the stateReason above is the only evidence \
593 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
594 startup reports a connection error that hides the cause').",
595 got.id,
596 got.state,
597 got.reason(),
598 ),
599 )
600 }
601
602 /// `GetMicrovm`.
603 ///
604 /// `microvmIdentifier` is a URI parameter, so an empty one collapses the path onto the
605 /// listing and asks a different question — see [`super::require_valid_identifier`]. This is
606 /// the read every wait loop makes, so the check also covers
607 /// [`Self::wait_for_state`] and everything built on it.
608 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
609 super::require_valid_identifier("microvmIdentifier", id)?;
610 let call = Call::get("GetMicrovm", paths::microvm(id));
611 let reply = send_with_retry(self.transport(), call).await?;
612 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
613 Ok(got.into())
614 }
615
616 /// `ListMicrovms`, read to its last page.
617 ///
618 /// # Why every page
619 ///
620 /// `maxResults` caps at 50, so a fleet above 50 VMs read from one page is a
621 /// **confidently wrong** answer rather than a missing one: the caller gets a list that
622 /// looks complete, and a VM absent from it is a VM nothing here will terminate. That is
623 /// the same argument [`ops::ListImagesResponseWire`] makes for the image listing, and a
624 /// fleet listing is the one a teardown reads.
625 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
626 let mut items = Vec::new();
627 let mut next_token: Option<String> = None;
628 loop {
629 let call = Call::get("ListMicrovms", paths::microvms_list(next_token.as_deref()));
630 let reply = send_with_retry(self.transport(), call).await?;
631 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
632 items.extend(page.items);
633 match page.next_token {
634 Some(token) => next_token = Some(token),
635 None => return Ok(items),
636 }
637 }
638 }
639
640 /// `SuspendMicrovm`. Freezes the VM; the caller waits for SUSPENDED.
641 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
642 super::require_valid_identifier("microvmIdentifier", id)?;
643 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
644 send_with_retry(self.transport(), call).await?;
645 Ok(())
646 }
647
648 /// `ResumeMicrovm`. Thaws the VM; no token re-delivery and no re-bootstrap.
649 pub async fn resume(&self, id: &str) -> Result<(), Error> {
650 super::require_valid_identifier("microvmIdentifier", id)?;
651 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
652 send_with_retry(self.transport(), call).await?;
653 Ok(())
654 }
655
656 /// `TerminateMicrovm`.
657 ///
658 /// The identifier check matters most on this one, and for a reason the others do not share:
659 /// the path is `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE`
660 /// addressed at the **collection**. There is no operation on that path and the service
661 /// refuses it, but "send a delete at the fleet and rely on the service to refuse" is not a
662 /// thing to leave to the service.
663 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
664 super::require_valid_identifier("microvmIdentifier", id)?;
665 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
666 send_with_retry(self.transport(), call).await?;
667 Ok(())
668 }
669
670 /// Mints a proxy token for the agent port (TRAP-7, TRAP-9).
671 ///
672 /// `expirationInMinutes` is capped at [`MAX_TOKEN_MINUTES`] here rather than passed
673 /// through, because over-asking is rejected and the rejection reads like a bad request
674 /// rather than like a ceiling. The *refresh* interval — half the ceiling — belongs to
675 /// the endpoint client that holds the token (T-W2-5), not here.
676 ///
677 /// Scoped to the agent port alone, which is right for every request this client makes on
678 /// its own behalf and **wrong for a caller reaching another port on the same VM**. That
679 /// caller wants [`Self::mint_auth_token_for`]; see [`ops::PortSpecification`] for the
680 /// measurement that made the distinction necessary.
681 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
682 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
683 .await
684 }
685
686 /// Mints a proxy token scoped to `ports`, whatever they are.
687 ///
688 /// The agent port is **not** added for you. A caller naming ports is answering the
689 /// question the token asks, and silently widening the scope would hand back a
690 /// credential broader than the one requested — the opposite of the mistake this exists
691 /// to fix, and a worse one, since it is invisible. A caller that needs both the agent
692 /// port and a workload port names both, which is what [`ProxyAuth`] does.
693 ///
694 /// The returned token still records the agent port as its *default* header port, because
695 /// that is the port a request carries when nobody names one.
696 ///
697 /// [`ProxyAuth`]: crate::session::ProxyAuth
698 pub async fn mint_auth_token_for(
699 &self,
700 id: &str,
701 ports: &[ops::PortSpecification],
702 ) -> Result<ProxyToken, Error> {
703 super::require_valid_identifier("microvmIdentifier", id)?;
704 if ports.is_empty() {
705 // The model declares `min: 1` on `allowedPorts`, so an empty list is a
706 // round-trip that comes back as a validation error naming a member the caller
707 // did not know it was sending. Refused here, with the reason.
708 return Err(Error::new(
709 ErrorKind::Precondition,
710 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
711 Name the ports this token is for — the agent port plus any workload port \
712 reached through the endpoint.",
713 ));
714 }
715 // `PortNumber` is `min: 1`, and the value that reaches here as a 0 is not a typo — it is
716 // a caller who took `ControlPlane::with_port(0)`'s default port, or who passed a
717 // `SocketAddr`'s port before binding. A token minted for port 0 authorizes nothing, and
718 // the proxy's refusal is `403 Access to port denied`, which reads like the token being
719 // wrong rather than its scope being empty (measured 2026-08-15, see
720 // `ops::PortSpecification`). Both ends of a range are checked, because `PortRange`'s two
721 // members name the same shape and a range starting at 0 is the same defect.
722 for spec in ports {
723 match spec {
724 ops::PortSpecification::One { port } => {
725 super::require_valid_port("allowedPorts[].port", *port)?;
726 }
727 ops::PortSpecification::Range { range } => {
728 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
729 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
730 }
731 // `allPorts` names no number, so there is nothing to bound.
732 ops::PortSpecification::All { .. } => {}
733 }
734 }
735 let wire = ops::CreateAuthTokenWire {
736 expiration_in_minutes: MAX_TOKEN_MINUTES,
737 allowed_ports: ports.to_vec(),
738 };
739 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
740 let reply = send_with_retry(self.transport(), call).await?;
741 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
742 ProxyToken::from_map(&minted.auth_token, self.port())
743 }
744
745 /// Mints a shell token: the credential `microvm shell` opens the PTY WebSocket with.
746 ///
747 /// `CreateMicrovmShellAuthToken`, which the VM must have been launched with
748 /// `SHELL_INGRESS` to answer — a VM launched without it gets a `ValidationException`
749 /// naming the connector, and `RunMicrovmRequest::with_shell` is how a launch requests
750 /// the measured pair. The request takes only the expiration; there is no
751 /// `allowedPorts`, because the shell is not a port (`docs/PLATFORM.md`, 2026-08-15).
752 ///
753 /// # This is `microvm shell`'s method, not exec's (TRAP-11)
754 ///
755 /// One interactive session is not programmatic exec — no exec ids, no idempotency,
756 /// no separated stdout/stderr, no exit codes — so nothing on the exec or session
757 /// path calls this, and the lifecycle test asserts that absence. A caller holding
758 /// the returned token has a real root PTY on the VM; treat it like the credential
759 /// it is.
760 ///
761 /// The expiration is pinned at [`MAX_TOKEN_MINUTES`] for the reason
762 /// [`Self::mint_auth_token`] gives: over-asking is rejected in a way that reads like
763 /// a bad request rather than like a ceiling. A shell session held past the ceiling
764 /// keeps its already-open WebSocket — the expiry gates the *handshake*, not an
765 /// established session.
766 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
767 super::require_valid_identifier("microvmIdentifier", id)?;
768 let wire = ops::CreateShellAuthTokenWire {
769 expiration_in_minutes: MAX_TOKEN_MINUTES,
770 };
771 let call = Call::post_json(
772 "CreateMicrovmShellAuthToken",
773 paths::shell_auth_token(id),
774 &wire,
775 )?;
776 let reply = send_with_retry(self.transport(), call).await?;
777 let minted: ops::CreateShellAuthTokenResponseWire =
778 reply.json("CreateMicrovmShellAuthToken")?;
779 ProxyToken::from_operation_map(
780 "CreateMicrovmShellAuthToken",
781 &minted.auth_token,
782 self.port(),
783 )
784 }
785}
786
787/// The states a suspend may end in.
788///
789/// TERMINATED is *wanted* rather than a failure: a VM that dies while suspending is a state
790/// to report, not an exception to raise out of the middle of a teardown.
791pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
792
793#[cfg(test)]
794mod tests {
795 use std::sync::Arc;
796
797 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
798 use super::*;
799 use crate::region::Region;
800
801 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
802 let fake = Arc::new(FakeControlPlane::new());
803 let clock = Arc::new(TestClock::new());
804 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
805 (plane, fake, clock)
806 }
807
808 /// **TRAP-5, the boundary.** 4096 bytes passes, 4097 is rejected — inclusive, as
809 /// measured 2026-08-07.
810 ///
811 /// **Falsification** — change the comparison to `<` and the 4096 case fails; change the
812 /// ceiling to 16384 (the number the docs and the model's own documentation string claim)
813 /// and the 4097 case fails.
814 #[test]
815 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
816 let at_ceiling = "a".repeat(4096);
817 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
818 assert_eq!(payload.len(), 4096);
819 assert_eq!(payload.as_str(), at_ceiling);
820
821 let over = "a".repeat(4097);
822 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
823 assert_eq!(error.kind(), ErrorKind::InvalidArg);
824 let message = error.to_string();
825 assert!(message.contains("4097 bytes"), "{message}");
826 assert!(
827 message.contains("ceiling of 4096"),
828 "the message must name the service-model ceiling: {message}"
829 );
830 assert!(message.contains("2025-09-09"), "{message}");
831 assert!(
832 message.contains("wrong by 4x"),
833 "the 16 KB claim has to be named as wrong: {message}"
834 );
835 }
836
837 /// **TRAP-5, the local-refusal half.** An over-ceiling payload means the fake records
838 /// **zero** calls: the launch is refused before any control-plane call.
839 #[tokio::test]
840 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
841 let (plane, fake, _) = planted();
842
843 // The payload type refuses first, so there is no way to even build the request.
844 let refused = RunHookPayload::new("a".repeat(4097));
845 assert!(refused.is_err());
846 assert_eq!(fake.calls().len(), 0);
847
848 // And the at-ceiling one does launch, so the guard is not simply refusing
849 // everything.
850 fake.answer(
851 "RunMicrovm",
852 Answer::ok(fake::microvm_response("PENDING", None)),
853 );
854 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
855 plane
856 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
857 .await
858 .expect("launches");
859 assert_eq!(fake.call_count("RunMicrovm"), 1);
860 }
861
862 /// The ceiling is measured in **bytes**, not characters. A payload of 2049 two-byte
863 /// characters is 4098 bytes and must be refused even though it is 2049 characters.
864 #[test]
865 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
866 let two_byte_chars = "é".repeat(2049);
867 assert_eq!(
868 two_byte_chars.chars().count(),
869 2049,
870 "well under 4096 chars"
871 );
872 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
873
874 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
875 assert!(error.to_string().contains("4098 bytes"), "{error}");
876
877 // 2048 of them is exactly 4096 bytes, which fits.
878 let exact = "é".repeat(2048);
879 assert_eq!(exact.len(), 4096);
880 RunHookPayload::new(exact).expect("exactly at the ceiling");
881 }
882
883 /// The identity material costs exactly its declared budget, and a launch without it is
884 /// byte-for-byte what the older constructors built.
885 ///
886 /// Measured by difference rather than trusted from the constant's comment, the same
887 /// discipline `env_contribution` applies: the ceiling is on the serialized string, so
888 /// only the serialized string can say what the material costs. The compatibility half
889 /// matters just as much — a pinned daemon that has never heard of identity must see no
890 /// change from a client that did not ask for it.
891 #[test]
892 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
893 let env = std::collections::HashMap::new();
894 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
895 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
896 assert_eq!(
897 without.as_str(),
898 none_asked.as_str(),
899 "no identity must produce byte-for-byte the payload this client always sent"
900 );
901
902 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
903 let with =
904 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
905 assert_eq!(
906 with.len() - without.len(),
907 protocol::identity::IDENTITY_PAYLOAD_BYTES,
908 "the budget constant must equal what the material really costs on the wire"
909 );
910
911 // And the payload parses as the daemon parses it, with both halves present.
912 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
913 assert_eq!(
914 parsed.identity_seed.as_deref(),
915 Some(identity.seed_field().as_str())
916 );
917 assert_eq!(
918 parsed.identity_host_public_key.as_deref(),
919 Some(identity.host_public_field().as_str())
920 );
921 }
922
923 /// The agent-token payload is the shape the daemon parses, and an oversized token is
924 /// caught even though the client builds the JSON — because the token is caller-supplied.
925 #[test]
926 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
927 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
928 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
929
930 // A caller passing a credential bundle rather than a bearer token is who this
931 // catches.
932 let huge = "x".repeat(4096);
933 let error = RunHookPayload::for_agent_token(&huge)
934 .expect_err("the JSON wrapper pushes it over the ceiling");
935 assert_eq!(error.kind(), ErrorKind::InvalidArg);
936 assert!(
937 error.to_string().contains("one bearer token fits"),
938 "{error}"
939 );
940 }
941
942 /// An empty launch env produces byte-for-byte the payload the token-only
943 /// constructor always produced, so a caller who never passes one is unaffected by
944 /// the field existing — and a daemon baked into an older image sees no new key.
945 ///
946 /// **Falsification** — always emit `"env"` and this fails on both assertions.
947 #[test]
948 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
949 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
950 .expect("an empty env fits");
951 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
952 assert_eq!(with_empty.as_str(), token_only.as_str());
953 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
954 assert!(
955 !with_empty.as_str().contains("env"),
956 "an empty env must not appear on the wire at all: {}",
957 with_empty.as_str()
958 );
959 }
960
961 /// A launch env is carried in the payload the daemon parses.
962 #[test]
963 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
964 let mut env = std::collections::HashMap::new();
965 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
966 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
967
968 // Parsed rather than string-compared, since a HashMap has no key order.
969 let value: serde_json::Value =
970 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
971 assert_eq!(value["agent_token"], "tok");
972 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
973 }
974
975 /// **The local 4096-byte refusal for a launch env.**
976 ///
977 /// The reason this is the case worth its own test rather than a repeat of the
978 /// ceiling test above: one bearer token has always fit with room to spare, so
979 /// before `env` existed the ceiling was effectively unreachable through the typed
980 /// constructor. A launch env is what makes it reachable, and botocore does not
981 /// enforce it client-side — the oversized request goes to the wire and comes back
982 /// as a `ValidationException` on a member the caller did not know they were
983 /// filling (`docs/PLATFORM.md`). So this refusal is the only local signal there is.
984 ///
985 /// The message must carry the byte count, the ceiling, and the env's share of it.
986 /// Without the share, a caller reading "4128 bytes, ceiling 4096" cannot tell
987 /// whether to shorten the token or drop a variable.
988 ///
989 /// **Falsification** — return `Ok` from `for_launch` without calling `new`, or drop
990 /// the byte-count clause from the message, and this goes red on the specific
991 /// assertion.
992 #[test]
993 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
994 let mut env = std::collections::HashMap::new();
995 // Comfortably over on its own, the way a set of session credentials is.
996 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
997
998 // The expected figure is derived from the same serialization the constructor
999 // does rather than written as a literal, because a literal here asserts my
1000 // arithmetic about JSON framing and the point is the *count*, not the framing.
1001 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1002 .to_string()
1003 .len();
1004 assert!(
1005 expected > 4096,
1006 "the fixture must actually be over: {expected}"
1007 );
1008
1009 let error = RunHookPayload::for_launch("tok", &env)
1010 .expect_err("a credential-scale launch env does not fit");
1011 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1012 let message = error.to_string();
1013
1014 assert!(
1015 message.contains("ceiling of 4096"),
1016 "the ceiling has to be named: {message}"
1017 );
1018 assert!(
1019 message.contains(&format!("{expected} bytes")),
1020 "the actual byte count has to be named: {message}"
1021 );
1022 assert!(
1023 message.contains("launch env contributed"),
1024 "the env's share of the budget is the actionable half: {message}"
1025 );
1026 assert!(
1027 message.contains("1 variable(s)"),
1028 "the variable count has to be named: {message}"
1029 );
1030 }
1031
1032 /// The refusal fires on the *serialized* payload, so a caller who measured their own
1033 /// values would be measuring the wrong thing.
1034 ///
1035 /// Two variables whose raw bytes sum to 4060 — under the ceiling — go over it once
1036 /// the JSON framing, the key names, and the quotes are counted. A check on the map
1037 /// rather than on the string would accept this and the launch would fail at AWS.
1038 #[test]
1039 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1040 let mut env = std::collections::HashMap::new();
1041 env.insert("A".to_string(), "a".repeat(2030));
1042 env.insert("B".to_string(), "b".repeat(2030));
1043 let raw: usize = env.values().map(String::len).sum();
1044 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1045 assert!(
1046 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1047 "the fixture only means something if the raw sum fits: {raw}"
1048 );
1049
1050 let error = RunHookPayload::for_launch("tok", &env)
1051 .expect_err("the serialized payload is over even though the raw values are not");
1052 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1053
1054 // And the boundary is still inclusive with an env in play: trimmed to fit, the
1055 // same shape succeeds — so the refusal is a ceiling and not a blanket ban.
1056 let mut fits = std::collections::HashMap::new();
1057 fits.insert("A".to_string(), "a".repeat(2000));
1058 fits.insert("B".to_string(), "b".repeat(2000));
1059 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1060 assert!(payload.len() <= 4096, "{}", payload.len());
1061 }
1062
1063 /// An empty payload is legal: the model's minimum is 0.
1064 #[test]
1065 fn an_empty_payload_is_legal() {
1066 let payload = RunHookPayload::new("").expect("min is 0");
1067 assert!(payload.is_empty());
1068 assert_eq!(payload.len(), 0);
1069 }
1070
1071 /// The launch request lands on the model's path with the model's member names, and the
1072 /// connectors are derived ARNs split across the two members (TRAP-4).
1073 #[tokio::test]
1074 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1075 let (plane, fake, _) = planted();
1076 fake.answer(
1077 "RunMicrovm",
1078 Answer::ok(fake::microvm_response("PENDING", None)),
1079 );
1080
1081 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1082 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1083 plane.run_microvm(request).await.expect("launches");
1084
1085 let calls = fake.calls();
1086 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1087 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1088
1089 let body = fake.first_body("RunMicrovm");
1090 assert_eq!(
1091 body["ingressNetworkConnectors"],
1092 serde_json::json!([
1093 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1094 ])
1095 );
1096 assert_eq!(
1097 body["egressNetworkConnectors"],
1098 serde_json::json!([
1099 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1100 ])
1101 );
1102 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1103 assert_eq!(body["maximumDurationInSeconds"], 3600);
1104 }
1105
1106 /// Without egress, the member is **absent** — which is how you get a VM with no
1107 /// outbound network.
1108 #[tokio::test]
1109 async fn a_launch_without_egress_omits_the_member_entirely() {
1110 let (plane, fake, _) = planted();
1111 fake.answer(
1112 "RunMicrovm",
1113 Answer::ok(fake::microvm_response("PENDING", None)),
1114 );
1115 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1116 plane
1117 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1118 .await
1119 .expect("launches");
1120
1121 let body = fake.first_body("RunMicrovm");
1122 assert!(
1123 body.get("egressNetworkConnectors").is_none(),
1124 "omitting egress is the whole mechanism: {body}"
1125 );
1126 assert!(body.get("ingressNetworkConnectors").is_some());
1127 }
1128
1129 /// **A pinned `imageVersion` reaches the wire, and an unpinned launch omits the member.**
1130 ///
1131 /// The absence half is the one that matters for compatibility: an unpinned launch has to
1132 /// emit byte-for-byte what this client always sent, so a `"imageVersion": null` on every
1133 /// launch would be a new member on a request that has worked for months. The presence half
1134 /// is what makes a canary a canary — the launch goes against the version named rather than
1135 /// against whatever became latest while it was starting.
1136 ///
1137 /// **Falsification** — run 2026-08-16. Drop the `image_version` assignment in
1138 /// `run_microvm` and the pinned assertion goes red with the member absent; remove
1139 /// `skip_serializing_if` from the wire field and the absence assertion goes red with a
1140 /// `null`.
1141 #[tokio::test]
1142 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1143 let (plane, fake, _) = planted();
1144 fake.answer(
1145 "RunMicrovm",
1146 Answer::ok(fake::microvm_response("PENDING", None)),
1147 );
1148 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1149 plane
1150 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1151 .await
1152 .expect("launches");
1153
1154 let body = fake.first_body("RunMicrovm");
1155 assert_eq!(body["imageVersion"], "2.0");
1156 assert_eq!(
1157 body["imageIdentifier"], "arn:image",
1158 "pinning a version does not replace the identifier; both are sent"
1159 );
1160
1161 let (plane, fake, _) = planted();
1162 fake.answer(
1163 "RunMicrovm",
1164 Answer::ok(fake::microvm_response("PENDING", None)),
1165 );
1166 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1167 plane
1168 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1169 .await
1170 .expect("launches");
1171 assert!(
1172 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1173 "an unpinned launch must send what this client always sent: {}",
1174 fake.first_body("RunMicrovm")
1175 );
1176 }
1177
1178 /// An invalid `imageVersion` is refused before any call — including on the launch path,
1179 /// which is where a rollback happens.
1180 ///
1181 /// A `ValidationException` about the request rather than about the version, arriving at the
1182 /// moment someone is re-pinning away from a bad build, is the least useful failure
1183 /// available. So the `Version` shape's three constraints are checked here.
1184 ///
1185 /// **Falsification** — run 2026-08-16. Delete the `require_valid_version` call from
1186 /// `run_microvm` and the zero-call assertion goes red.
1187 #[tokio::test]
1188 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1189 for bad in ["", "2.0\n", "a b"] {
1190 let (plane, fake, _) = planted();
1191 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1192 let error = plane
1193 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1194 .await
1195 .expect_err(&format!("{bad:?} is not a legal Version"));
1196 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1197 assert!(
1198 error.to_string().contains("imageVersion"),
1199 "{bad:?}: {error}"
1200 );
1201 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1202 }
1203 }
1204
1205 /// The connectors reaching the wire are ARNs, never bare names — a bare name is rejected
1206 /// with "Malformed network connector ARN", and it is the value that reads most natural
1207 /// to write.
1208 #[tokio::test]
1209 async fn no_bare_connector_name_reaches_the_wire() {
1210 let (plane, fake, _) = planted();
1211 fake.answer(
1212 "RunMicrovm",
1213 Answer::ok(fake::microvm_response("PENDING", None)),
1214 );
1215 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1216 plane
1217 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1218 .await
1219 .expect("launches");
1220
1221 let body = fake.first_body("RunMicrovm");
1222 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1223 for value in body[member].as_array().expect("a list") {
1224 let text = value.as_str().expect("a string");
1225 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1226 assert_ne!(text, "ALL_INGRESS");
1227 assert_ne!(text, "INTERNET_EGRESS");
1228 }
1229 }
1230 }
1231
1232 /// An out-of-range duration is refused before any call.
1233 #[tokio::test]
1234 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1235 let (plane, fake, _) = planted();
1236 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1237 let mut request = RunMicrovmRequest::new("arn:image", payload);
1238 request.max_duration_sec = 28_801;
1239
1240 let error = plane
1241 .run_microvm(request)
1242 .await
1243 .expect_err("over the ceiling");
1244 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1245 assert_eq!(fake.calls().len(), 0);
1246 }
1247
1248 /// **TRAP-8.** A VM that reaches a terminal state before RUNNING is rejected with the
1249 /// state **and** the `stateReason` in the message.
1250 ///
1251 /// The message is asserted rather than merely the error, because a timeout also produces
1252 /// an error — so "an error occurred" would pass against the exact defect this closes.
1253 ///
1254 /// **Falsification** — remove the `fail_on` branch from `wait_for_state` and this test
1255 /// fails with an `ErrorKind::Timeout` whose message names neither the state nor the
1256 /// reason. Verified; see the packet's guard proofs.
1257 #[tokio::test]
1258 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1259 let (plane, fake, _) = planted();
1260 fake.answer(
1261 "GetMicrovm",
1262 Answer::ok(fake::microvm_response(
1263 "TERMINATED",
1264 Some("run hook returned 500"),
1265 )),
1266 );
1267
1268 let error = plane
1269 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1270 .await
1271 .expect_err("a dead VM must not be waited out");
1272
1273 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1274 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1275 let message = error.to_string();
1276 assert!(
1277 message.contains("TERMINATED"),
1278 "the state must be attached: {message}"
1279 );
1280 assert!(
1281 message.contains("run hook returned 500"),
1282 "the stateReason must be attached: {message}"
1283 );
1284 assert!(
1285 message.contains("lifecycle hook failed"),
1286 "the likely cause is worth naming: {message}"
1287 );
1288 assert!(
1289 fake.call_count("GetMicrovm") == 1,
1290 "it fails fast rather than polling to the deadline"
1291 );
1292 }
1293
1294 /// Every terminal state fails the launch fast, not just TERMINATED. SUSPENDED reached
1295 /// before RUNNING is also a death — the VM never came up.
1296 ///
1297 /// The states are read from `constants::TERMINAL_STATES` rather than listed, so a state
1298 /// added there is covered here without a second edit — and the count is asserted so the
1299 /// loop cannot silently become empty.
1300 #[tokio::test]
1301 async fn every_terminal_state_fails_a_launch_fast() {
1302 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1303 for state in crate::constants::TERMINAL_STATES {
1304 let (plane, fake, _) = planted();
1305 fake.answer(
1306 "GetMicrovm",
1307 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1308 );
1309 let error = plane
1310 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1311 .await
1312 .expect_err(&format!("{state} before RUNNING is a death"));
1313 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1314 assert!(error.to_string().contains(state), "{state}: {error}");
1315 assert!(
1316 error.to_string().contains("hook timeout"),
1317 "{state}: {error}"
1318 );
1319 assert_eq!(
1320 fake.call_count("GetMicrovm"),
1321 1,
1322 "{state} must fail fast rather than polling to the deadline"
1323 );
1324 }
1325 }
1326
1327 /// A terminal state with **no** `stateReason` says so rather than printing an empty
1328 /// string: "the service said nothing" is a different diagnosis from "the service said
1329 /// nothing useful".
1330 #[tokio::test]
1331 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1332 let (plane, fake, _) = planted();
1333 fake.answer(
1334 "GetMicrovm",
1335 Answer::ok(fake::microvm_response("TERMINATED", None)),
1336 );
1337
1338 let error = plane
1339 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1340 .await
1341 .expect_err("still a death");
1342 let message = error.to_string();
1343 assert!(message.contains("no stateReason"), "{message}");
1344 // The failure this guards against is an absent reason rendering as an empty string,
1345 // which reads as the service having answered with nothing rather than not having
1346 // answered. `: .` is what that looks like in this message's shape.
1347 assert!(
1348 !message.contains(r#"["RUNNING"]: ."#),
1349 "an absent reason must not render as an empty string: {message}"
1350 );
1351 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1352 }
1353
1354 /// The wait returns on RUNNING, having polled through PENDING.
1355 #[tokio::test]
1356 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1357 let (plane, fake, _) = planted();
1358 fake.answer(
1359 "GetMicrovm",
1360 Answer::ok(fake::microvm_response("PENDING", None)),
1361 )
1362 .answer(
1363 "GetMicrovm",
1364 Answer::ok(fake::microvm_response("RUNNING", None)),
1365 );
1366
1367 let vm = plane
1368 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1369 .await
1370 .expect("reaches RUNNING");
1371 assert_eq!(vm.state, "RUNNING");
1372 assert_eq!(
1373 vm.endpoint,
1374 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1375 );
1376 assert_eq!(fake.call_count("GetMicrovm"), 2);
1377 }
1378
1379 /// A launch that never comes up ends at its deadline with a `Timeout` — which is a
1380 /// *different* error from TRAP-8's, and the distinction is the whole reason the TRAP-8
1381 /// test asserts on the message.
1382 #[tokio::test]
1383 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1384 let (plane, fake, _) = planted();
1385 fake.answer(
1386 "GetMicrovm",
1387 Answer::ok(fake::microvm_response("PENDING", None)),
1388 );
1389
1390 let error = plane
1391 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1392 .await
1393 .expect_err("the deadline elapses");
1394 assert_eq!(error.kind(), ErrorKind::Timeout);
1395 assert_ne!(
1396 error.kind(),
1397 ErrorKind::LaunchDied,
1398 "a timeout is not a death, and conflating them is what hid the cause"
1399 );
1400 assert!(error.to_string().contains("PENDING"), "{error}");
1401 }
1402
1403 /// The resume path fails fast on the **dead** states and waits through SUSPENDED —
1404 /// which is the state a resume is called from, so failing on it would fail every resume.
1405 #[tokio::test]
1406 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1407 let (plane, fake, _) = planted();
1408 fake.answer(
1409 "GetMicrovm",
1410 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1411 )
1412 .answer(
1413 "GetMicrovm",
1414 Answer::ok(fake::microvm_response("RUNNING", None)),
1415 );
1416
1417 let vm = plane
1418 .wait_for_state(
1419 "mvm-abc123",
1420 &["RUNNING"],
1421 &crate::constants::DEAD_STATES,
1422 WaitOpts::for_launch(),
1423 )
1424 .await
1425 .expect("SUSPENDED is a waypoint on the resume path");
1426 assert_eq!(vm.state, "RUNNING");
1427
1428 let (plane, fake, _) = planted();
1429 fake.answer(
1430 "GetMicrovm",
1431 Answer::ok(fake::microvm_response(
1432 "TERMINATED",
1433 Some("suspended window elapsed"),
1434 )),
1435 );
1436 let error = plane
1437 .wait_for_state(
1438 "mvm-abc123",
1439 &["RUNNING"],
1440 &crate::constants::DEAD_STATES,
1441 WaitOpts::for_launch(),
1442 )
1443 .await
1444 .expect_err("a terminated VM never reaches RUNNING");
1445 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1446 assert!(
1447 error.to_string().contains("suspended window elapsed"),
1448 "{error}"
1449 );
1450 }
1451
1452 /// A suspend **wants** TERMINATED as well as SUSPENDED: a VM that dies while suspending
1453 /// is a state to report, not an exception out of a teardown.
1454 #[tokio::test]
1455 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1456 let (plane, fake, _) = planted();
1457 fake.answer(
1458 "GetMicrovm",
1459 Answer::ok(fake::microvm_response("TERMINATED", None)),
1460 );
1461
1462 let vm = plane
1463 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1464 .await
1465 .expect("a suspend reports the state it reached");
1466 assert_eq!(vm.state, "TERMINATED");
1467 }
1468
1469 /// The lifecycle calls land on the model's paths and methods. `POST` for suspend and
1470 /// resume, `DELETE` for terminate — transcribed from each operation's `http` trait.
1471 #[tokio::test]
1472 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1473 let (plane, fake, _) = planted();
1474 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1475 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1476 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1477
1478 plane.suspend("mvm-1").await.expect("suspends");
1479 plane.resume("mvm-1").await.expect("resumes");
1480 plane.terminate("mvm-1").await.expect("terminates");
1481
1482 let calls = fake.calls();
1483 use super::super::transport::Method;
1484 assert_eq!(
1485 calls
1486 .iter()
1487 .map(|call| (call.operation, call.method, call.path.as_str()))
1488 .collect::<Vec<_>>(),
1489 [
1490 (
1491 "SuspendMicrovm",
1492 Method::Post,
1493 "/2025-09-09/microvms/mvm-1/suspend"
1494 ),
1495 (
1496 "ResumeMicrovm",
1497 Method::Post,
1498 "/2025-09-09/microvms/mvm-1/resume"
1499 ),
1500 (
1501 "TerminateMicrovm",
1502 Method::Delete,
1503 "/2025-09-09/microvms/mvm-1"
1504 ),
1505 ]
1506 );
1507 }
1508
1509 /// **TRAP-7.** The minted token is read out of the `authToken` **map**, and
1510 /// [`ProxyToken::headers`] emits both headers.
1511 #[tokio::test]
1512 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1513 let (plane, fake, _) = planted();
1514 fake.answer(
1515 "CreateMicrovmAuthToken",
1516 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1517 );
1518
1519 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1520 assert_eq!(token.value(), "opaque-proxy-token");
1521 assert_eq!(token.port(), 9000);
1522
1523 let headers = token.headers();
1524 assert_eq!(
1525 headers[0],
1526 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1527 );
1528 assert_eq!(
1529 headers[1],
1530 ("X-aws-proxy-port", "9000".to_string()),
1531 "the auth header without the port header is rejected in a way that reads like a \
1532 bad token"
1533 );
1534
1535 let body = fake.first_body("CreateMicrovmAuthToken");
1536 assert_eq!(body["expirationInMinutes"], 60);
1537 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1538 }
1539
1540 /// The shell mint posts to the model's `shell-auth-token` route with **only** the
1541 /// expiration in its body, and reads the credential out of the `authToken` map —
1542 /// the same `TokenParts` discipline as TRAP-7.
1543 ///
1544 /// The `allowedPorts` absence is asserted rather than assumed, because it is the
1545 /// measured heart of the operation: `CreateMicrovmShellAuthToken` takes no ports at
1546 /// all — the shell is not a port — and a client that sent them would be sending a
1547 /// member the model does not declare (`docs/PLATFORM.md`, 2026-08-15).
1548 ///
1549 /// **Falsification** — watched fail 2026-08-31: pointing the call at
1550 /// `paths::auth_token` fails the path assertion at the `assert_eq!` on `calls[0].path`.
1551 #[tokio::test]
1552 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
1553 let (plane, fake, _) = planted();
1554 fake.answer(
1555 "CreateMicrovmShellAuthToken",
1556 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
1557 );
1558
1559 let token = plane
1560 .mint_shell_auth_token("mvm-1")
1561 .await
1562 .expect("mints a shell token");
1563 assert_eq!(token.value(), "opaque-shell-jwe");
1564
1565 let calls = fake.calls();
1566 assert_eq!(calls.len(), 1);
1567 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
1568 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
1569
1570 let body = fake.first_body("CreateMicrovmShellAuthToken");
1571 assert_eq!(body["expirationInMinutes"], 60);
1572 assert_eq!(
1573 body.get("allowedPorts"),
1574 None,
1575 "the shell is not a port: the request declares no allowedPorts member"
1576 );
1577 assert_eq!(
1578 body.as_object().map(serde_json::Map::len),
1579 Some(1),
1580 "expirationInMinutes is the body's only member: {body}"
1581 );
1582 }
1583
1584 /// The shell mint refuses an empty identifier locally, like every other
1585 /// identifier-in-the-URI operation: an empty segment would address a route the model
1586 /// does not declare.
1587 #[tokio::test]
1588 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
1589 let (plane, fake, _) = planted();
1590 let error = plane
1591 .mint_shell_auth_token("")
1592 .await
1593 .expect_err("an empty identifier is refused");
1594 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1595 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
1596 }
1597
1598 /// A launch request's `Debug` does not print the agent token.
1599 ///
1600 /// Asserted on the whole [`RunMicrovmRequest`] rather than on the payload alone,
1601 /// because that is the type a caller actually logs — and it keeps its derived `Debug`,
1602 /// so what is pinned here is that the derive inherits [`RunHookPayload`]'s hand-written
1603 /// one. **Falsification** — restore `#[derive(Debug)]` on [`RunHookPayload`] and the
1604 /// `SECRETTOKEN` assertion fails through the derived request `Debug`.
1605 #[test]
1606 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1607 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1608
1609 let rendered = format!("{payload:?}");
1610 assert!(
1611 !rendered.contains("SECRETTOKEN"),
1612 "the agent token reached a Debug string: {rendered}"
1613 );
1614 assert!(
1615 rendered.contains(&format!("{} bytes", payload.len())),
1616 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1617 );
1618
1619 let request = RunMicrovmRequest::new("arn:image", payload);
1620 let rendered = format!("{request:?}");
1621 assert!(rendered.contains("arn:image"), "{rendered}");
1622 assert!(
1623 !rendered.contains("SECRETTOKEN"),
1624 "the agent token reached a Debug string through the request: {rendered}"
1625 );
1626 }
1627
1628 /// A minted token's `Debug` names its headers and never its value, so a logged
1629 /// launch does not log a credential.
1630 ///
1631 /// The mirror of `session::proxy`'s `a_proxy_token_debug_does_not_print_the_credential`,
1632 /// on the control-plane side of the conversion. **Falsification** — restore
1633 /// `#[derive(Debug)]` on [`ProxyToken`] and this fails on the `secret` assertion.
1634 #[test]
1635 fn a_proxy_token_debug_does_not_print_the_credential() {
1636 let mut map = std::collections::BTreeMap::new();
1637 map.insert(
1638 PROXY_AUTH_HEADER.to_string(),
1639 "eyJhbGciOi-secret".to_string(),
1640 );
1641 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1642
1643 let rendered = format!("{token:?}");
1644 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1645 assert!(rendered.contains("9000"), "{rendered}");
1646 assert!(
1647 !rendered.contains("secret"),
1648 "the token value reached a Debug string: {rendered}"
1649 );
1650 }
1651
1652 /// A response whose map lacks the auth key is **retryable**, because minting happens
1653 /// inside the request retry path and a throttle mid-run must not kill a healthy trial.
1654 #[test]
1655 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1656 let mut map = std::collections::BTreeMap::new();
1657 map.insert("SomeOtherKey".to_string(), "value".to_string());
1658
1659 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1660 assert_eq!(
1661 error.wire_kind(),
1662 Some(crate::error::WireKind::AuthTokenMint)
1663 );
1664 assert!(error.retryable(), "minting is inside the retry path");
1665 assert!(
1666 error.to_string().contains("is a map, not a string"),
1667 "{error}"
1668 );
1669 }
1670
1671 /// The token is minted for the configured port, not always 9000.
1672 #[tokio::test]
1673 async fn the_token_is_minted_for_the_configured_port() {
1674 let fake = Arc::new(FakeControlPlane::new());
1675 let plane =
1676 ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1677 .with_port(8080)
1678 .expect("8080 is a legal port");
1679 fake.answer(
1680 "CreateMicrovmAuthToken",
1681 Answer::ok(fake::auth_token_response("t")),
1682 );
1683
1684 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1685 assert_eq!(token.port(), 8080);
1686 assert_eq!(
1687 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1688 8080
1689 );
1690 }
1691
1692 /// **TRAP-11, revised, across a full lifecycle.** Drive create, wait, launch, wait,
1693 /// mint, suspend, resume, terminate, delete — and then assert the recorder saw
1694 /// **zero** shell-auth calls, and that the launch request carried **exactly the
1695 /// connectors the caller asked for**, nothing added and nothing dropped.
1696 ///
1697 /// The body scan used to assert zero `SHELL_INGRESS` anywhere, standing on the claim
1698 /// that no intent could name it. `ConnectorIntent::ShellIngress` exists now — the
1699 /// measured ground is that one interactive session is not programmatic exec, not
1700 /// that the connector is unspeakable (`docs/PLATFORM.md`, 2026-08-15) — so the
1701 /// honest assertion is set-equality against the request: a caller that asked for
1702 /// `[ALL_INGRESS]` plus egress gets that set on the wire and no other.
1703 ///
1704 /// The shell-auth scans stay as counts, and their ground moved once more:
1705 /// `mint_shell_auth_token` exists now (issue #69, `microvm shell`'s method), so the
1706 /// absence being pinned is no longer the method's but the **exec path's** — a
1707 /// lifecycle that asked for no shell drives everything exec can do and emits zero
1708 /// shell operations. Nothing here calls the shell mint, and that is the assertion.
1709 ///
1710 /// **Falsification** — make any lifecycle step (`run_microvm`, `mint_auth_token`,
1711 /// suspend/resume/terminate) also touch the shell route, or call
1712 /// `mint_shell_auth_token` from this lifecycle: the operation scan and the path scan
1713 /// both fail. Make `run_microvm` inject a connector the request did not carry: the
1714 /// set-equality assertions fail.
1715 #[tokio::test]
1716 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
1717 let (plane, fake, _) = planted();
1718 fake.answer(
1719 "CreateMicrovmImage",
1720 Answer::created(fake::create_image_response("img")),
1721 )
1722 .answer(
1723 "GetMicrovmImage",
1724 Answer::ok(fake::get_image_response("img", "CREATED")),
1725 )
1726 .answer(
1727 "RunMicrovm",
1728 Answer::ok(fake::microvm_response("PENDING", None)),
1729 )
1730 .answer(
1731 "GetMicrovm",
1732 Answer::ok(fake::microvm_response("RUNNING", None)),
1733 )
1734 .answer(
1735 "CreateMicrovmAuthToken",
1736 Answer::ok(fake::auth_token_response("proxy-token")),
1737 )
1738 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1739 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1740 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1741 .answer(
1742 "ListMicrovmImageVersions",
1743 Answer::ok(fake::list_versions_response("1")),
1744 )
1745 .answer(
1746 "DeleteMicrovmImage",
1747 Answer::ok(fake::delete_image_response()),
1748 );
1749
1750 // Everything this client can do, in the order a real run does it.
1751 let request = super::super::CreateImageRequest::new(
1752 "img",
1753 b"binary".to_vec(),
1754 "s3://bucket/img.zip",
1755 "arn:aws:iam::123456789012:role/build",
1756 );
1757 let image = plane.create_image(request).await.expect("creates");
1758 plane
1759 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1760 .await
1761 .expect("becomes usable");
1762
1763 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1764 let vm = plane
1765 .run_microvm(
1766 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1767 )
1768 .await
1769 .expect("launches");
1770 plane
1771 .wait_for_running(&vm.id, WaitOpts::for_launch())
1772 .await
1773 .expect("reaches RUNNING");
1774 plane.mint_auth_token(&vm.id).await.expect("mints");
1775 plane.suspend(&vm.id).await.expect("suspends");
1776 plane.resume(&vm.id).await.expect("resumes");
1777 plane.terminate(&vm.id).await.expect("terminates");
1778 plane
1779 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1780 .await;
1781
1782 // The lifecycle really ran, so the zero counts below are about absence rather than
1783 // about nothing having happened.
1784 let operations = fake.operations();
1785 assert!(
1786 operations.len() >= 10,
1787 "the lifecycle must actually have run: {operations:?}"
1788 );
1789 assert!(operations.contains(&"CreateMicrovmAuthToken"));
1790
1791 assert_eq!(
1792 fake.call_count("CreateMicrovmShellAuthToken"),
1793 0,
1794 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
1795 for no shell must mint no shell token: {operations:?}"
1796 );
1797 for operation in &operations {
1798 assert!(
1799 !operation.contains("Shell"),
1800 "no operation may be a shell one: {operation}"
1801 );
1802 }
1803 for path in fake.paths() {
1804 assert!(
1805 !path.contains("shell"),
1806 "no request may reach a shell route: {path}"
1807 );
1808 }
1809
1810 // The launch carried exactly the connector set the caller asked for —
1811 // `ALL_INGRESS` from the default plus the egress `with_egress` added — and this
1812 // client injected nothing of its own. Set-equality cuts both ways: a connector
1813 // the caller never named (the shell, or anything else) fails it, and so does a
1814 // dropped one.
1815 let launch = fake.first_body("RunMicrovm");
1816 assert_eq!(
1817 launch["ingressNetworkConnectors"],
1818 serde_json::json!([
1819 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1820 ]),
1821 "ingress is exactly what the caller asked for"
1822 );
1823 assert_eq!(
1824 launch["egressNetworkConnectors"],
1825 serde_json::json!([
1826 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1827 ]),
1828 "egress is exactly what the caller asked for"
1829 );
1830 }
1831
1832 /// The measured pair `[HTTP_INGRESS, SHELL_INGRESS]` reaches the wire intact — the
1833 /// combination `docs/PLATFORM.md` (2026-08-15) records as launching and minting a
1834 /// shell token successfully. Both are ingress connectors, so both must land in
1835 /// `ingressNetworkConnectors`; a split that only recognized `ALL_INGRESS` as ingress
1836 /// would silently drop them, and this is the test that catches that.
1837 #[tokio::test]
1838 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
1839 let (plane, fake, _) = planted();
1840 fake.answer(
1841 "RunMicrovm",
1842 Answer::ok(fake::microvm_response("PENDING", None)),
1843 );
1844
1845 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1846 let mut request = RunMicrovmRequest::new("arn:image", payload);
1847 request.connectors = vec![
1848 super::super::ConnectorIntent::HttpIngress,
1849 super::super::ConnectorIntent::ShellIngress,
1850 ];
1851 plane.run_microvm(request).await.expect("launches");
1852
1853 let launch = fake.first_body("RunMicrovm");
1854 assert_eq!(
1855 launch["ingressNetworkConnectors"],
1856 serde_json::json!([
1857 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
1858 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
1859 ]),
1860 "both fine-grained ingress connectors reach the wire, in the order asked"
1861 );
1862 assert_eq!(
1863 launch.get("egressNetworkConnectors"),
1864 None,
1865 "no egress was asked for, so the member is absent rather than empty"
1866 );
1867 }
1868
1869 /// `ALL_INGRESS` combined with the shell connector is refused **before the wire**.
1870 /// The platform accepts the invalid set at launch — the VM reaches RUNNING and bills
1871 /// — and rejects it only when a shell token is minted (`docs/PLATFORM.md`,
1872 /// 2026-08-15). A local refusal is the only one that costs nothing.
1873 ///
1874 /// **Falsification** — run 2026-08-31: with the combination guard in
1875 /// [`ControlPlane::run_microvm`] disabled, this failed on the error assertion.
1876 /// Restored.
1877 #[tokio::test]
1878 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
1879 let (plane, fake, _) = planted();
1880
1881 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1882 let mut request = RunMicrovmRequest::new("arn:image", payload);
1883 request
1884 .connectors
1885 .push(super::super::ConnectorIntent::ShellIngress);
1886 let error = plane
1887 .run_microvm(request)
1888 .await
1889 .expect_err("the invalid combination is refused locally");
1890
1891 assert!(
1892 error
1893 .to_string()
1894 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
1895 "the refusal names the pair that works: {error}"
1896 );
1897 assert_eq!(
1898 fake.call_count("RunMicrovm"),
1899 0,
1900 "nothing launched, so nothing billed"
1901 );
1902 }
1903
1904 /// **W1, measured constraint 1, second half.** `SHELL_INGRESS` without `HTTP_INGRESS`
1905 /// is the same launches-bills-fails-late shape as `ALL_INGRESS` plus a finer connector:
1906 /// the measured pair that launches and mints a shell token is `[HTTP_INGRESS,
1907 /// SHELL_INGRESS]` (`docs/PLATFORM.md`), no measurement says a shell-only ingress set
1908 /// even mints, and the platform accepts an invalid set at launch — the VM reaches
1909 /// RUNNING and bills before any refusal appears. Refused here instead, before the wire.
1910 ///
1911 /// **Falsification** — run 2026-08-31. This test was written before the guard clause
1912 /// existed and failed on both assertions (`run_microvm` reached the wire and launched);
1913 /// adding the shell-without-HTTP refusal in `run_microvm` turned it green.
1914 #[tokio::test]
1915 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
1916 let (plane, fake, _) = planted();
1917
1918 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1919 let mut request = RunMicrovmRequest::new("arn:image", payload);
1920 request.connectors = vec![
1921 super::super::ConnectorIntent::ShellIngress,
1922 super::super::ConnectorIntent::Egress,
1923 ];
1924 let error = plane
1925 .run_microvm(request)
1926 .await
1927 .expect_err("shell ingress without HTTP ingress is refused locally");
1928
1929 assert!(
1930 error.to_string().contains("HTTP_INGRESS"),
1931 "the refusal names the missing connector: {error}"
1932 );
1933 assert_eq!(
1934 fake.call_count("RunMicrovm"),
1935 0,
1936 "nothing launched, so nothing billed"
1937 );
1938 }
1939
1940 /// **Issue #23.** The fleet listing follows `nextToken`, so a fleet larger than one page
1941 /// is not silently truncated.
1942 ///
1943 /// One page is a **confidently wrong** answer rather than a missing one: the caller gets a
1944 /// list that looks complete, and a VM absent from it is a VM nothing here will terminate.
1945 ///
1946 /// The assertion is the fleet **length** and the presence of the page-two ids, which is
1947 /// what a test against the single-page code cannot satisfy.
1948 ///
1949 /// **Falsification** — run 2026-08-15. Replace the `Some(token)` arm with
1950 /// `Some(_) => return Ok(items)` and this fails with 2 VMs instead of 3, and both
1951 /// page-two `contains` assertions go red. Restored.
1952 #[tokio::test]
1953 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
1954 let (plane, fake, _) = planted();
1955 fake.answer(
1956 "ListMicrovms",
1957 Answer::ok(fake::list_microvms_page(
1958 &["mvm-page1-a", "mvm-page1-b"],
1959 Some("fleet-page-2"),
1960 )),
1961 )
1962 .answer(
1963 "ListMicrovms",
1964 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
1965 );
1966
1967 let fleet = plane.list_microvms().await.expect("lists");
1968 assert_eq!(
1969 fleet.len(),
1970 3,
1971 "a fleet spanning two pages is three VMs, not two"
1972 );
1973
1974 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
1975 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
1976 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
1977 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
1978
1979 let paths = fake.paths();
1980 assert!(
1981 paths[1].contains("nextToken=fleet-page-2"),
1982 "the second request must carry the first page's token: {}",
1983 paths[1]
1984 );
1985 assert!(
1986 !paths[0].contains('?'),
1987 "the first request carries no cursor: {}",
1988 paths[0]
1989 );
1990 }
1991
1992 /// **Issue #25.** The `idlePolicy` the service reports is carried rather than dropped.
1993 ///
1994 /// The comment on [`ops::IdlePolicy`] used to claim `suspendedDurationSeconds` "exists
1995 /// only in the request". A live `GetMicrovm` on a RUNNING VM disagrees, which is what the
1996 /// fake's body now reflects — so this test is over a response shape that was measured
1997 /// rather than assumed.
1998 ///
1999 /// **Falsification** — drop `idle_policy` from the `From<MicrovmResponseWire>` impl and
2000 /// this does not compile; set it to `None` there and every field assertion goes red.
2001 #[tokio::test]
2002 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2003 let (plane, fake, _) = planted();
2004 fake.answer(
2005 "GetMicrovm",
2006 Answer::ok(fake::microvm_response("RUNNING", None)),
2007 );
2008
2009 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2010 let policy = vm
2011 .idle_policy
2012 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2013 assert_eq!(policy.max_idle_duration_seconds, 1800);
2014 assert_eq!(
2015 policy.suspended_duration_seconds, 600,
2016 "the member the comment claimed was request-only"
2017 );
2018 assert!(!policy.auto_resume_enabled);
2019 }
2020
2021 /// A response with no `idlePolicy` still parses, and the absence is `None` rather than a
2022 /// default. The model does not mark the member required on either response shape, so an
2023 /// invented `0` would be a window nobody configured.
2024 #[tokio::test]
2025 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2026 let (plane, fake, _) = planted();
2027 fake.answer(
2028 "GetMicrovm",
2029 Answer::ok(
2030 r#"{
2031 "microvmId": "mvm-1",
2032 "state": "RUNNING",
2033 "endpoint": "https://e",
2034 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2035 "imageVersion": "1"
2036 }"#,
2037 ),
2038 );
2039
2040 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2041 assert_eq!(vm.idle_policy, None);
2042 }
2043
2044 /// A transport failure is retried and then succeeds, so a connection reset on the way to
2045 /// a launch is not a failed launch.
2046 #[tokio::test]
2047 async fn a_transport_failure_is_retried_rather_than_reported() {
2048 let (plane, fake, _) = planted();
2049 fake.fail_transport("GetMicrovm", 2).answer(
2050 "GetMicrovm",
2051 Answer::ok(fake::microvm_response("RUNNING", None)),
2052 );
2053
2054 let vm = plane
2055 .get_microvm("mvm-1")
2056 .await
2057 .expect("the retry succeeds");
2058 assert_eq!(vm.state, "RUNNING");
2059 assert_eq!(
2060 fake.call_count("GetMicrovm"),
2061 3,
2062 "two failures then a success"
2063 );
2064 }
2065
2066 // ── issue #24's guards on the launch and lifecycle paths ─────────────────
2067
2068 /// **`max_idle_sec: 59` no longer reaches the wire.**
2069 ///
2070 /// The measurement issue #24 made, turned into a test. The exemption it replaces was not a
2071 /// wrong number — it was a correct fact about botocore's `VALIDATED_METADATA_ATTRS` applied to
2072 /// a client that does not use botocore, so `IdlePolicy.maxIdleDurationSeconds` was the one
2073 /// constraint in the model with a *reason* for having no guard rather than an oversight.
2074 ///
2075 /// The fake has `RunMicrovm` answered, so a missing guard shows as a launch that **succeeded**
2076 /// with 59 on the wire rather than as some other failure. That is what makes the zero-call
2077 /// assertion the load-bearing one.
2078 ///
2079 /// **Guard proof** — run 2026-08-16. Delete `require_idle_duration` from `run_microvm` and
2080 /// this fails on `expect_err`, with the fake recording `RunMicrovm` once and
2081 /// `idlePolicy.maxIdleDurationSeconds: 59` in its body.
2082 #[tokio::test]
2083 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2084 for under in [0, 1, 59] {
2085 let (plane, fake, _) = planted();
2086 fake.answer(
2087 "RunMicrovm",
2088 Answer::ok(fake::microvm_response("PENDING", None)),
2089 );
2090
2091 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2092 let mut request = RunMicrovmRequest::new("arn:image", payload);
2093 request.max_idle_sec = under;
2094
2095 let error = plane
2096 .run_microvm(request)
2097 .await
2098 .expect_err("the model's min is 60");
2099 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2100 let message = error.to_string();
2101 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2102 assert!(message.contains(&under.to_string()), "{message}");
2103 assert_eq!(
2104 fake.calls().len(),
2105 0,
2106 "{under} was refused locally; before this guard it was serialized and sent"
2107 );
2108 }
2109
2110 // 60 exactly does launch, so the boundary is inclusive and the guard is not refusing
2111 // every idle window.
2112 let (plane, fake, _) = planted();
2113 fake.answer(
2114 "RunMicrovm",
2115 Answer::ok(fake::microvm_response("PENDING", None)),
2116 );
2117 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2118 let mut request = RunMicrovmRequest::new("arn:image", payload);
2119 request.max_idle_sec = 60;
2120 plane.run_microvm(request).await.expect("60 is the minimum");
2121 assert_eq!(
2122 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2123 60
2124 );
2125 }
2126
2127 /// **The launch's `executionRoleArn` and `imageIdentifier`, refused before the call.**
2128 ///
2129 /// `executionRoleArn` is filled from an infra config on every real launch — the CLI reads it
2130 /// out of `MICROVM_EXECUTION_ROLE_ARN` — so a malformed one is a `ValidationException` about a
2131 /// member the caller never typed. Naming it locally is the whole difference.
2132 ///
2133 /// **Guard proof** — run 2026-08-16. Delete `require_valid_role_arn("executionRoleArn", …)`
2134 /// and the role rows launch successfully with the bad ARN on the wire; delete
2135 /// `require_valid_identifier` and the identifier rows do.
2136 #[tokio::test]
2137 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2138 /// What to break, aliased for the reason clippy's `type_complexity` gives — and the same
2139 /// alias `image.rs`'s create-path table uses, so the two read alike.
2140 type Mutate = fn(&mut RunMicrovmRequest);
2141 let rows: [(&str, Mutate, &str); 6] = [
2142 (
2143 "an execution role that is a bare name",
2144 |request| {
2145 request.execution_role_arn = Some("execution-role".to_string());
2146 },
2147 "role *name*",
2148 ),
2149 (
2150 "an execution role with eleven account digits",
2151 |request| {
2152 request.execution_role_arn =
2153 Some("arn:aws:iam::12345678901:role/exec".to_string());
2154 },
2155 "exactly twelve digits",
2156 ),
2157 (
2158 "an execution role that is a function ARN",
2159 |request| {
2160 request.execution_role_arn =
2161 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2162 },
2163 "RoleArn pattern",
2164 ),
2165 (
2166 "an empty image identifier",
2167 |request| {
2168 request.image_identifier = String::new();
2169 },
2170 "collapses the path",
2171 ),
2172 (
2173 "a 257-character image identifier",
2174 |request| {
2175 request.image_identifier = "a".repeat(257);
2176 },
2177 "MicrovmImageArn permits 2048",
2178 ),
2179 (
2180 "an image version with a newline",
2181 |request| {
2182 request.image_version = Some("2.0\n".to_string());
2183 },
2184 "contains whitespace",
2185 ),
2186 ];
2187
2188 for (label, mutate, expected) in rows {
2189 let (plane, fake, _) = planted();
2190 fake.answer(
2191 "RunMicrovm",
2192 Answer::ok(fake::microvm_response("PENDING", None)),
2193 );
2194
2195 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2196 let mut request = RunMicrovmRequest::new("arn:image", payload);
2197 mutate(&mut request);
2198
2199 let error = plane
2200 .run_microvm(request)
2201 .await
2202 .expect_err(&format!("{label} must be refused"));
2203 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2204 assert!(
2205 error.to_string().contains(expected),
2206 "{label}: wanted {expected:?}, got {error}"
2207 );
2208 assert_eq!(fake.calls().len(), 0, "{label}");
2209 }
2210
2211 // The control case: a launch carrying a real execution role, a pinned version, and the
2212 // account's actual ARN shape goes through.
2213 let (plane, fake, _) = planted();
2214 fake.answer(
2215 "RunMicrovm",
2216 Answer::ok(fake::microvm_response("PENDING", None)),
2217 );
2218 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2219 let mut request = RunMicrovmRequest::new(
2220 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2221 payload,
2222 );
2223 request.execution_role_arn =
2224 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2225 request.image_version = Some("1.0".to_string());
2226 plane
2227 .run_microvm(request)
2228 .await
2229 .expect("a realistic pinned launch with a real execution role");
2230 assert_eq!(fake.call_count("RunMicrovm"), 1);
2231 }
2232
2233 /// **The `microvmIdentifier` guard on every lifecycle operation, proved by the call count.**
2234 ///
2235 /// `TerminateMicrovm` is the one worth reading twice. Its path is
2236 /// `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE` addressed at
2237 /// the fleet collection. The service has no operation there and refuses it, but "send a delete
2238 /// at the collection and trust the service" is not a thing to leave to the service.
2239 ///
2240 /// **Guard proof** — run 2026-08-16. Delete `require_valid_identifier` from any one of the
2241 /// five and its assertion goes red with `calls: 1`.
2242 #[tokio::test]
2243 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2244 for bad in ["", &"a".repeat(257)] {
2245 let (plane, fake, _) = planted();
2246 // Every operation answered, so a missing guard is a *successful* call rather than a
2247 // different failure.
2248 fake.answer(
2249 "GetMicrovm",
2250 Answer::ok(fake::microvm_response("RUNNING", None)),
2251 );
2252 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2253 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2254 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2255 fake.answer(
2256 "CreateMicrovmAuthToken",
2257 Answer::ok(fake::auth_token_response("t")),
2258 );
2259
2260 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2261 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2262 plane.resume(bad).await.expect_err("ResumeMicrovm");
2263 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2264 plane
2265 .mint_auth_token(bad)
2266 .await
2267 .expect_err("CreateMicrovmAuthToken");
2268 plane
2269 .wait_for_running(bad, WaitOpts::for_launch())
2270 .await
2271 .expect_err("GetMicrovm, in a loop");
2272
2273 assert_eq!(
2274 fake.calls().len(),
2275 0,
2276 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2277 path is a DELETE addressed at the collection."
2278 );
2279 }
2280 }
2281
2282 /// **Issue #24's `PortNumber` half: a token is never minted for port 0.**
2283 ///
2284 /// The measurement that makes this worth a guard rather than a comment is in
2285 /// [`ops::PortSpecification`]: a token whose `allowedPorts` does not cover the port a request
2286 /// names is refused with **403 `Access to port denied`**, and on the WebSocket path it is close
2287 /// code 1006 with no reason. So a token minted for port 0 authorizes nothing and fails in a
2288 /// way that reads like a bad credential rather than like an empty scope.
2289 ///
2290 /// Both ends of a range are checked as well as the single-port form, because `PortRange`'s two
2291 /// members name the same shape and a range starting at 0 is the same defect.
2292 ///
2293 /// **Guard proof** — run 2026-08-16. Delete the `for spec in ports` loop from
2294 /// `mint_auth_token_for` and all three rows mint successfully with `{"port": 0}` or
2295 /// `{"range": {"startPort": 0, …}}` on the wire.
2296 #[tokio::test]
2297 async fn no_proxy_token_is_minted_for_port_zero() {
2298 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2299 (
2300 "a single port of 0",
2301 ops::PortSpecification::port(0),
2302 "allowedPorts[].port",
2303 ),
2304 (
2305 "a range starting at 0",
2306 ops::PortSpecification::range(0, 9000),
2307 "allowedPorts[].range.startPort",
2308 ),
2309 (
2310 "a range ending at 0",
2311 ops::PortSpecification::range(0, 0),
2312 "allowedPorts[].range.startPort",
2313 ),
2314 ];
2315
2316 for (label, spec, member) in rows {
2317 let (plane, fake, _) = planted();
2318 fake.answer(
2319 "CreateMicrovmAuthToken",
2320 Answer::ok(fake::auth_token_response("t")),
2321 );
2322
2323 let error = plane
2324 .mint_auth_token_for("mvm-1", &[spec])
2325 .await
2326 .expect_err(&format!("{label} must be refused"));
2327 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2328 let message = error.to_string();
2329 assert!(message.contains(member), "{label}: {message}");
2330 assert!(
2331 message.contains("authorizes nothing"),
2332 "{label}: the 403 consequence has to be in the message: {message}"
2333 );
2334 assert_eq!(fake.calls().len(), 0, "{label}");
2335 }
2336
2337 // A zero port among legal ones is still refused: the check is per-entry, not on the
2338 // first. A token minted with `[{port: 9000}, {port: 0}]` would look scoped correctly and
2339 // carry a member the service refuses the whole request over.
2340 let (plane, fake, _) = planted();
2341 fake.answer(
2342 "CreateMicrovmAuthToken",
2343 Answer::ok(fake::auth_token_response("t")),
2344 );
2345 plane
2346 .mint_auth_token_for(
2347 "mvm-1",
2348 &[
2349 ops::PortSpecification::port(9000),
2350 ops::PortSpecification::port(0),
2351 ],
2352 )
2353 .await
2354 .expect_err("every entry is checked, not the first");
2355 assert_eq!(fake.calls().len(), 0);
2356
2357 // And the three legal forms mint, including `allPorts` — which names no number, so there
2358 // is nothing to bound and the guard must not refuse it.
2359 for legal in [
2360 ops::PortSpecification::port(1),
2361 ops::PortSpecification::port(65_535),
2362 ops::PortSpecification::range(1, 65_535),
2363 ops::PortSpecification::all(),
2364 ] {
2365 let (plane, fake, _) = planted();
2366 fake.answer(
2367 "CreateMicrovmAuthToken",
2368 Answer::ok(fake::auth_token_response("t")),
2369 );
2370 plane
2371 .mint_auth_token_for("mvm-1", &[legal])
2372 .await
2373 .expect("a legal port specification mints");
2374 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2375 }
2376 }
2377}