Skip to main content

microvms_core/
sandbox.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The `Sandbox` lifecycle: one VM's whole life, with the state machine the symspec
3//! model describes made true of the code.
4//!
5//! Composes [`crate::control`] and [`crate::session`] into the surface the CLI and the
6//! bindings use: build, run, suspend, resume, terminate, the launch-time suspended
7//! window (STATE-12), and teardown in the order that does not leak.
8//!
9//! # The lifecycle is a field, and every transition is guarded
10//!
11//! [`Lifecycle`] is the symspec's `vm_state` verbatim, and [`Sandbox`] carries the other
12//! four variables beside it — `token_installed`, `image_exists`, `was_terminated`,
13//! `bootstrap_count`. The Z3 proofs over that model (bootstrap at most once, suspend from
14//! non-RUNNING unreachable, TERMINATED never returns to RUNNING) are proofs about *this*
15//! struct's reachable states, which is only worth something if the transitions here are
16//! the only way to move it. They are: every one of those fields is private, and every
17//! mutation happens in one of the five methods below.
18//!
19//! # Runtime-checked rather than typestate, deliberately
20//!
21//! The packet offered `Sandbox<Running>` returning a `Suspended` handle, which would make
22//! STATE-5's wrong call a compile error rather than a local refusal — strictly stronger on
23//! the ladder in [`crate`]. It is not what landed, for the reason the packet names as
24//! acceptable: T-W3-8 wraps **one** object for PyO3 and napi-rs, and a type whose Rust
25//! identity changes on every transition cannot be one `#[pyclass]`. A typestate sandbox
26//! would be re-erased into a runtime-checked enum at the binding boundary, so the check
27//! would exist twice with the binding's copy being the one most callers actually hit.
28//!
29//! What is kept from the typestate idea is the part that costs nothing: the check happens
30//! **before** the wire call, so a suspend from SUSPENDED is refused with zero
31//! control-plane calls rather than answered by AWS. The test asserts the call count, which
32//! is the observable that distinguishes the two.
33//!
34//! # The suspended window is the client's alone (STATE-12)
35//!
36//! `suspendedDurationSeconds` exists only in the `RunMicrovm` **request**. `GetMicrovm`
37//! does not return it, so the client that sent the launch is the only party that can name
38//! the window it asked for — and the launch-time `idlePolicy` *terminates* a suspended VM
39//! once that window passes, which means "resume later" silently stops working. A resume
40//! past the window is refused locally, before `ResumeMicrovm`, because the alternative is
41//! calling and reading the failure: the service answers about a terminated id, which is
42//! not the same statement as "the window you set at launch closed", and getting there
43//! costs the full poll timeout first.
44//!
45//! # Teardown never raises, and the log group is last
46//!
47//! [`Sandbox::terminate`] returns a [`TeardownReport`] rather than a `Result`. It runs
48//! where a caller's `finally` would, and an error raised there replaces the real failure
49//! with a teardown failure — the real one being the one worth reading.
50//!
51//! The order is VM, then image (retrying), then the log group **last**, because the
52//! service can recreate a group deleted before its image. See
53//! [`TeardownReport::undeleted`] for what this crate can and cannot delete.
54//!
55//! # There is no `Drop` that tears down
56//!
57//! Rust has no context manager and `Drop` cannot await. A `Drop` that blocked on a runtime
58//! would deadlock inside one; a `Drop` that spawned would race the process exit. So
59//! [`Sandbox`]'s `Drop` only **warns** about a live VM, naming the id, and the rule is
60//! that a caller calls [`Sandbox::terminate`] explicitly.
61
62use std::sync::Arc;
63use std::time::Duration;
64
65use crate::control::{
66    ControlPlane, CreateImageRequest, Image, Microvm, RunHookPayload, RunMicrovmRequest, WaitOpts,
67};
68use crate::error::{Error, ErrorKind};
69use crate::region::Region;
70use crate::session::{Session, TokenMinter};
71
72/// The default launch wait: five minutes, matching the Python client's `ready_timeout_sec`.
73pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(300);
74
75/// The default lifecycle wait for suspend, resume, and terminate.
76pub const DEFAULT_LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(300);
77
78/// How many times a teardown retries the image delete.
79///
80/// Twenty, from the Python client: an image in `CREATING` refuses deletion and a VM still
81/// terminating holds a reference, so this is the difference between a clean account and a
82/// billed leak rather than politeness.
83pub const DEFAULT_DELETE_ATTEMPTS: u32 = 20;
84
85/// The gap between image-delete attempts.
86pub const DEFAULT_DELETE_BACKOFF: Duration = Duration::from_secs(15);
87
88/// How often a lifecycle wait polls.
89const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
90
91/// The symspec's `vm_state`, verbatim.
92///
93/// Six states and no others, which is the S1 half of this module: a lifecycle held as a
94/// `String` would let `"RUNNING "` and `"Running"` both exist, and every guard below would
95/// have to decide which it meant.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum Lifecycle {
98    /// The initial state, and the state a launch is accepted into (STATE-1).
99    Pending,
100    /// The run hook answered with a success status and the token is installed (STATE-2).
101    Running,
102    /// A suspend was accepted and the platform has not yet reported it complete (STATE-4).
103    Suspending,
104    /// The platform reported suspension complete (STATE-6).
105    Suspended,
106    /// A terminate was accepted (STATE-9).
107    Terminating,
108    /// The platform reported termination complete (STATE-10).
109    Terminated,
110}
111
112impl Lifecycle {
113    /// The name the service uses for this state, which is also what an error message says.
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Lifecycle::Pending => "PENDING",
117            Lifecycle::Running => "RUNNING",
118            Lifecycle::Suspending => "SUSPENDING",
119            Lifecycle::Suspended => "SUSPENDED",
120            Lifecycle::Terminating => "TERMINATING",
121            Lifecycle::Terminated => "TERMINATED",
122        }
123    }
124
125    /// Whether a VM in this state is still billing, which is what a `Drop` warning is for.
126    pub fn is_live(self) -> bool {
127        matches!(
128            self,
129            Lifecycle::Pending | Lifecycle::Running | Lifecycle::Suspending | Lifecycle::Suspended
130        )
131    }
132}
133
134impl std::fmt::Display for Lifecycle {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(self.as_str())
137    }
138}
139
140/// Everything a launch needs, with the defaults the Python client measured.
141///
142/// `agent_token` is optional because the common case is a per-VM secret nobody needs to
143/// see; a caller who has one already — a harness minting its own, or a retry that must
144/// reuse the first attempt's — passes it.
145#[derive(Clone, Debug)]
146pub struct RunRequest {
147    /// The image to launch, or `None` for the one [`Sandbox::build_image`] built.
148    pub image_identifier: Option<String>,
149    /// `imageVersion`, or `None` for the image's own latest active version.
150    ///
151    /// Pinning is what makes a canary a canary: without it a launch takes whatever version is
152    /// latest at the moment the call lands, which is not necessarily the one the caller just
153    /// built. Paired with [`crate::control::ControlPlane::set_image_version_status`] it is also
154    /// the rollback — set the bad version INACTIVE, re-pin here to the good one — and a version
155    /// set INACTIVE refuses to launch when named here.
156    ///
157    /// See [`crate::control::RunMicrovmRequest::image_version`], which this forwards to.
158    pub image_version: Option<String>,
159    /// The execution role. Optional in the model; every real launch needs one.
160    pub execution_role_arn: Option<String>,
161    /// The bearer token the daemon will accept, or `None` to mint one.
162    pub agent_token: Option<String>,
163    /// Base environment for every exec in the launched VM, delivered in the same
164    /// `runHookPayload` as the token.
165    ///
166    /// The daemon applies this *under* each request's own `env`, so a per-exec value
167    /// wins on a key both set. Empty by default, and an empty map produces byte-for-byte
168    /// the payload this client always sent — a caller who never touches this field
169    /// cannot be affected by the field existing.
170    ///
171    /// It shares the token's 4096-byte payload budget, and the check is local:
172    /// [`crate::control::RunHookPayload::for_launch`] refuses an over-ceiling payload
173    /// before any call, naming the byte count and how much of it the env is. That
174    /// matters here more than for the token, because one bearer token has always fit
175    /// with room to spare and a map of credentials does not.
176    pub launch_env: std::collections::HashMap<String, String>,
177    /// Whether to generate and deliver a tunnel identity (#70 layer 3).
178    ///
179    /// On, the launch generates two fresh x25519 seeds, delivers the VM's seed and the
180    /// host's public key in the same payload as the token, and [`RunOutcome`]-equivalent
181    /// state on the session keeps the [`crate::identity::TunnelIdentity`] — the host secret
182    /// and the VM's public pin — for `tunnel --verify-identity` to prove the far end.
183    /// Off (the default) sends byte-for-byte the payload this client always sent.
184    ///
185    /// A flag rather than always-on, deliberately: the material spends 137 bytes of the
186    /// payload's measured 4096-byte budget, and a caller near the ceiling with a launch env
187    /// deserves to choose which feature gets the room.
188    pub identity: bool,
189    /// Whether to request the egress connector. Off omits it from the request; measured
190    /// 2026-09-12 the platform gave a connector-less VM outbound network anyway
191    /// (`docs/PLATFORM.md`).
192    pub egress: bool,
193    /// Whether to launch shell-capable: the ingress set becomes the measured pair
194    /// `[HTTP_INGRESS, SHELL_INGRESS]` instead of `ALL_INGRESS`, which is what
195    /// `CreateMicrovmShellAuthToken` requires and what `microvm shell` attaches to.
196    /// Off (the default) launches byte-for-byte what this client always sent.
197    pub shell: bool,
198    /// `idlePolicy.maxIdleDurationSeconds`.
199    pub max_idle_sec: u32,
200    /// `idlePolicy.suspendedDurationSeconds` — the window STATE-12 refuses past.
201    pub suspended_sec: u32,
202    /// `idlePolicy.autoResumeEnabled`.
203    pub auto_resume: bool,
204    /// `maximumDurationInSeconds`, checked against 1..=28800 before the call.
205    pub max_duration_sec: u32,
206    /// How long to wait for RUNNING.
207    pub ready_timeout: Duration,
208    /// A label for the run token (TRAP-1). Never the token.
209    pub token_scope: Option<String>,
210}
211
212impl Default for RunRequest {
213    fn default() -> Self {
214        Self {
215            image_identifier: None,
216            image_version: None,
217            execution_role_arn: None,
218            agent_token: None,
219            launch_env: std::collections::HashMap::new(),
220            identity: false,
221            egress: false,
222            shell: false,
223            max_idle_sec: 600,
224            suspended_sec: 600,
225            auto_resume: false,
226            max_duration_sec: 3_600,
227            ready_timeout: DEFAULT_READY_TIMEOUT,
228            token_scope: None,
229        }
230    }
231}
232
233impl RunRequest {
234    /// A launch with the measured defaults: ingress only, ten-minute idle and suspended
235    /// windows, a one-hour maximum duration, no auto-resume.
236    pub fn new() -> Self {
237        Self::default()
238    }
239
240    /// Launches `identifier` rather than the built image.
241    #[must_use]
242    pub fn with_image(mut self, identifier: impl Into<String>) -> Self {
243        self.image_identifier = Some(identifier.into());
244        self
245    }
246
247    /// Requests the egress connector, which is what gives the VM outbound network.
248    #[must_use]
249    pub fn with_egress(mut self) -> Self {
250        self.egress = true;
251        self
252    }
253
254    /// Requests a shell-capable launch. See [`RunRequest::shell`].
255    #[must_use]
256    pub fn with_shell(mut self) -> Self {
257        self.shell = true;
258        self
259    }
260
261    /// Pins the launch to one `imageVersion`. See the field for why.
262    #[must_use]
263    pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
264        self.image_version = Some(version.into());
265        self
266    }
267
268    /// Sets the suspended window this sandbox will refuse a resume past (STATE-12).
269    #[must_use]
270    pub fn with_suspended_sec(mut self, seconds: u32) -> Self {
271        self.suspended_sec = seconds;
272        self
273    }
274
275    /// Reuses a caller-supplied agent token rather than minting one.
276    #[must_use]
277    pub fn with_agent_token(mut self, token: impl Into<String>) -> Self {
278        self.agent_token = Some(token.into());
279        self
280    }
281
282    /// Generates and delivers a tunnel identity with the launch (#70 layer 3).
283    ///
284    /// After a `run` with this set, [`Sandbox::tunnel_identity`] holds what
285    /// `tunnel --verify-identity` needs: the host's secret and the VM's public pin.
286    #[must_use]
287    pub fn with_identity(mut self) -> Self {
288        self.identity = true;
289        self
290    }
291
292    /// Adds one launch-environment variable, which every exec in the VM starts with.
293    ///
294    /// One pair per call rather than a whole map, because that is how a caller builds
295    /// one — from flags, from a config file, one credential at a time — and a
296    /// map-taking setter makes the second call silently discard the first. The field is
297    /// public for a caller who really does hold a map.
298    #[must_use]
299    pub fn with_launch_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
300        self.launch_env.insert(key.into(), value.into());
301        self
302    }
303}
304
305/// What a teardown should delete beyond the VM itself.
306///
307/// Both deletions are opt-in, because both destroy something a caller may still want: the
308/// image is reusable across runs, and the log group is where a failed build's only evidence
309/// lives.
310#[derive(Clone, Copy, Debug)]
311pub struct TeardownOpts {
312    /// Whether to delete the image the sandbox built.
313    pub delete_image: bool,
314    /// Whether the build log group should be deleted.
315    ///
316    /// This crate **cannot** delete it — CloudWatch is not in the dependency set — so
317    /// asking names the group in [`TeardownReport::undeleted`] rather than removing it.
318    /// See that field for why naming is the honest answer rather than a silent success.
319    pub delete_log_group: bool,
320    /// How many times the image delete is retried.
321    pub delete_attempts: u32,
322    /// The gap between image-delete attempts.
323    pub delete_backoff: Duration,
324    /// How long to wait for TERMINATED, or `None` to return as soon as the terminate call
325    /// is accepted.
326    ///
327    /// `None` by default, matching the Python client: the caller is on the way out, and a
328    /// teardown that blocked five minutes on a state nobody reads is five minutes of a CI
329    /// job. A caller that needs STATE-10 *observed* passes
330    /// [`TeardownOpts::waiting_for_terminated`].
331    pub wait_for_terminated: Option<Duration>,
332}
333
334impl Default for TeardownOpts {
335    fn default() -> Self {
336        Self {
337            delete_image: false,
338            delete_log_group: false,
339            delete_attempts: DEFAULT_DELETE_ATTEMPTS,
340            delete_backoff: DEFAULT_DELETE_BACKOFF,
341            wait_for_terminated: None,
342        }
343    }
344}
345
346impl TeardownOpts {
347    /// Deletes the image as well as the VM.
348    #[must_use]
349    pub fn deleting_image(mut self) -> Self {
350        self.delete_image = true;
351        self
352    }
353
354    /// Asks for the log group too, which names it rather than deleting it.
355    #[must_use]
356    pub fn deleting_log_group(mut self) -> Self {
357        self.delete_log_group = true;
358        self
359    }
360
361    /// Waits for TERMINATED before returning (STATE-10).
362    #[must_use]
363    pub fn waiting_for_terminated(mut self) -> Self {
364        self.wait_for_terminated = Some(DEFAULT_LIFECYCLE_TIMEOUT);
365        self
366    }
367}
368
369/// What a teardown did, and what it left behind.
370///
371/// Returned rather than raised. See the module docs: this runs where a `finally` would.
372#[derive(Clone, Debug, Default, Eq, PartialEq)]
373pub struct TeardownReport {
374    /// Identifiers of everything a caller asked to have deleted that still exists.
375    ///
376    /// The CLI emits these (CLI-6), which is the whole reason they are identifiers rather
377    /// than a boolean: a leak nobody can name is a leak nobody can clean up.
378    ///
379    /// Two things land here. A delete that was attempted and failed — an image whose twenty
380    /// attempts all hit a conflict. And the build **log group**, which this crate cannot
381    /// delete at all: no CloudWatch client exists anywhere in this workspace, and a logs
382    /// API for one delete call is a bigger surface than the leak it would hide (an
383    /// `aws-sdk-cloudwatchlogs` edge is a reasonable future add if teardown grows more
384    /// log-group work). Naming it is strictly better than the
385    /// alternatives: deleting it is unavailable, and silently succeeding would report a
386    /// clean teardown over six accumulated log groups — which is how the leak was found in
387    /// the first place.
388    pub undeleted: Vec<String>,
389    /// Whether the terminate call was accepted.
390    pub terminate_accepted: bool,
391    /// Whether the image was deleted, or `None` when deletion was not asked for.
392    pub image_deleted: Option<bool>,
393    /// The lifecycle state the sandbox ended in.
394    pub lifecycle: Option<Lifecycle>,
395    /// Every failure the teardown swallowed, in the order it hit them.
396    ///
397    /// Kept because a teardown that never raises is a teardown whose failures are invisible
398    /// otherwise, and the first one is usually the cause of the rest.
399    pub failures: Vec<String>,
400}
401
402impl TeardownReport {
403    /// Whether anything a caller asked for was left behind.
404    pub fn leaked(&self) -> bool {
405        !self.undeleted.is_empty()
406    }
407}
408
409/// Mints proxy tokens for one MicroVM through the control plane.
410///
411/// The bridge between the two lanes: `ControlPlane::mint_auth_token` answers a
412/// `control::ProxyToken`, [`TokenMinter`] wants a `session::ProxyToken`, and the `From`
413/// impl at `session/proxy.rs` is the conversion — one `.into()` at the boundary, which is
414/// why that impl is `From` rather than a named function.
415///
416/// The plane sits behind an `Arc` rather than a reference because the minter must outlive
417/// the call that built the session: minting happens inside the request path on every later
418/// request, which is what makes it happen at all (TRAP-9).
419struct ControlPlaneMinter {
420    control: Arc<ControlPlane>,
421    microvm_id: String,
422}
423
424impl TokenMinter for ControlPlaneMinter {
425    fn mint(
426        &self,
427    ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
428        Box::pin(async move {
429            let minted = self.control.mint_auth_token(&self.microvm_id).await?;
430            Ok(minted.into())
431        })
432    }
433
434    /// Overrides the default, which ignores the ports and delegates.
435    ///
436    /// This is the minter that has a control plane behind it, so it is the one that can
437    /// actually widen a token's scope — and without this override
438    /// `Session::connect_headers(8080)` would keep answering a header pair behind a token
439    /// scoped to 9000 only, which the proxy refuses with 403 `Access to port denied`. See
440    /// [`TokenMinter::mint_for_ports`] for the measurement.
441    fn mint_for_ports(
442        &self,
443        ports: &[u16],
444    ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
445        let specs: Vec<crate::control::ops::PortSpecification> = ports
446            .iter()
447            .map(|port| crate::control::ops::PortSpecification::port(*port))
448            .collect();
449        Box::pin(async move {
450            let minted = self
451                .control
452                .mint_auth_token_for(&self.microvm_id, &specs)
453                .await?;
454            Ok(minted.into())
455        })
456    }
457}
458
459/// One MicroVM's whole life.
460///
461/// See the module docs for the state machine, the window, and why teardown is explicit.
462pub struct Sandbox {
463    control: Arc<ControlPlane>,
464    image: Option<Image>,
465    microvm: Option<Microvm>,
466    session: Option<Session>,
467    /// A backend for the session [`Sandbox::run`] builds, or `None` for the real HTTP one.
468    ///
469    /// The daemon-side sibling of `with_control_plane`'s transport seam: a test that
470    /// scripts the control plane can launch, but the session `run` then builds would dial
471    /// a real endpoint. This lets the same test script the daemon too. Never set in
472    /// production — `Session::builder` picks reqwest when no backend is given.
473    session_backend: Option<crate::session::SharedBackend>,
474
475    // ── the symspec's five variables ─────────────────────────────────────────
476    lifecycle: Lifecycle,
477    token_installed: bool,
478    image_exists: bool,
479    was_terminated: bool,
480    bootstrap_count: u32,
481
482    /// The window from *our own* `RunMicrovm` request. `GetMicrovm` does not return it.
483    suspended_window: Option<Duration>,
484    /// The clock reading when the suspend call was accepted, or `None` when not suspended.
485    suspended_at: Option<Duration>,
486    /// Set by [`Sandbox::terminate`], so `Drop` can tell an abandoned VM from a torn-down
487    /// one.
488    torn_down: bool,
489    /// The launch's tunnel identity, when [`RunRequest::identity`] asked for one.
490    ///
491    /// Holds the host secret and the VM's *public* pin — the VM's own secret was dropped
492    /// the moment the payload was built ([`crate::identity::LaunchIdentity::keep`]), so
493    /// nothing on the host side can impersonate the VM it launched.
494    tunnel_identity: Option<crate::identity::TunnelIdentity>,
495}
496
497impl std::fmt::Debug for Sandbox {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        // No agent token: it is a credential, and a `Debug` that printed it would put it in
500        // every log line that formats a sandbox.
501        f.debug_struct("Sandbox")
502            .field("lifecycle", &self.lifecycle)
503            .field("microvm", &self.microvm.as_ref().map(|vm| &vm.id))
504            .field("image", &self.image.as_ref().map(|image| &image.identifier))
505            .field("token_installed", &self.token_installed)
506            .field("bootstrap_count", &self.bootstrap_count)
507            .field("was_terminated", &self.was_terminated)
508            .field("suspended_window", &self.suspended_window)
509            .finish_non_exhaustive()
510    }
511}
512
513impl Sandbox {
514    /// Resolves credentials for `region` and returns a sandbox with nothing launched.
515    ///
516    /// TRAP-6 is closed by [`Region`] before this is reached, which is why there is no
517    /// region check here.
518    pub async fn new(region: Region) -> Result<Self, Error> {
519        Ok(Self::with_control_plane(ControlPlane::new(region).await?))
520    }
521
522    /// A sandbox over an already-built [`ControlPlane`].
523    ///
524    /// The seam every test uses, and public for the same reason `ControlPlane::with_transport`
525    /// is: a caller who wants a non-default port, or a fake transport, has already built the
526    /// plane, and a sandbox constructible only from a [`Region`] would be a sandbox no test
527    /// can drive without AWS.
528    ///
529    /// The plane's own clock is what times the suspended window, rather than a second one
530    /// this type would own. Two clocks in one lifecycle is the trap the daemon's turmoil
531    /// tests record: a window measured on one clock while the poll loop sleeps on another
532    /// calls a closed window open.
533    pub fn with_control_plane(control: ControlPlane) -> Self {
534        Self {
535            control: Arc::new(control),
536            image: None,
537            microvm: None,
538            session: None,
539            session_backend: None,
540            lifecycle: Lifecycle::Pending,
541            token_installed: false,
542            image_exists: false,
543            was_terminated: false,
544            bootstrap_count: 0,
545            suspended_window: None,
546            suspended_at: None,
547            torn_down: false,
548            tunnel_identity: None,
549        }
550    }
551
552    /// Routes the session [`Sandbox::run`] builds through `backend` instead of real HTTP.
553    ///
554    /// The daemon-side half of the test seam `with_control_plane` opens: scripting the
555    /// control plane gets a test through the launch, and this gets it through everything
556    /// the launched session then does — uploads, execs, downloads — without a daemon.
557    pub fn with_session_backend(mut self, backend: crate::session::SharedBackend) -> Self {
558        self.session_backend = Some(backend);
559        self
560    }
561
562    // ── the symspec's five variables, readable ───────────────────────────────
563
564    /// The lifecycle state, which is the symspec's `vm_state`.
565    pub fn lifecycle(&self) -> Lifecycle {
566        self.lifecycle
567    }
568
569    /// Whether the agent token has been installed (STATE-2).
570    pub fn token_installed(&self) -> bool {
571        self.token_installed
572    }
573
574    /// Whether an image is recorded as existing (STATE-1).
575    pub fn image_exists(&self) -> bool {
576        self.image_exists
577    }
578
579    /// Whether this VM was ever terminated (STATE-11).
580    pub fn was_terminated(&self) -> bool {
581        self.was_terminated
582    }
583
584    /// How many times the token has been installed. Never above one (STATE-3).
585    pub fn bootstrap_count(&self) -> u32 {
586        self.bootstrap_count
587    }
588
589    /// The image, once built.
590    pub fn image(&self) -> Option<&Image> {
591        self.image.as_ref()
592    }
593
594    /// The VM as the service last described it.
595    pub fn microvm(&self) -> Option<&Microvm> {
596        self.microvm.as_ref()
597    }
598
599    /// The session, once launched.
600    pub fn session(&self) -> Option<&Session> {
601        self.session.as_ref()
602    }
603
604    /// The suspended window this sandbox asked for at launch, once it has launched.
605    pub fn suspended_window(&self) -> Option<Duration> {
606        self.suspended_window
607    }
608
609    /// The tunnel identity, when the launch asked for one ([`RunRequest::identity`]).
610    ///
611    /// What `tunnel --verify-identity` verifies with: the host's secret and the VM's public
612    /// pin. `None` on a launch that did not ask, and the tunnel route then refuses an
613    /// identity request with close code 4401 rather than downgrading.
614    pub fn tunnel_identity(&self) -> Option<&crate::identity::TunnelIdentity> {
615        self.tunnel_identity.as_ref()
616    }
617
618    /// The agent port the control plane was built with: the hooks port on every image
619    /// this sandbox builds, and the port the daemon listens on in every VM it launches.
620    /// Read-only; `agents::AgentVm` needs it to derive a Dockerfile whose `AGENTD_PORT`
621    /// agrees with the launch.
622    pub fn port(&self) -> u16 {
623        self.control.port()
624    }
625
626    // ── build ────────────────────────────────────────────────────────────────
627
628    /// Builds an image and waits for it to become usable.
629    ///
630    /// Every local guard runs inside [`ControlPlane::create_image`], before the call —
631    /// which matters because the create happens *after* the caller's artifact upload, so a
632    /// rejection AWS raises costs the upload first.
633    pub async fn build_image(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
634        let size = request.size;
635        let created = self.control.create_image(request).await?;
636        let mut built = self
637            .control
638            .wait_for_image(&created.identifier, size, WaitOpts::default())
639            .await?;
640        // The logging config survives the wait from the create's own record, because the
641        // readback cannot carry it: `GetMicrovmImage` reports no logging member, and the
642        // resolved log stream's discriminator was minted inside `create_image` — this is
643        // the only copy, and dropping it here would leave the caller unable to name the
644        // stream their build wrote to.
645        built.log_group = created.log_group;
646        built.log_stream = created.log_stream;
647        // Recorded before any launch, because a built image exists whether or not anything
648        // is ever run from it — and the teardown has to be able to name it either way.
649        self.image_exists = true;
650        self.image = Some(built);
651        Ok(self.image.as_ref().expect("just assigned"))
652    }
653
654    /// The artifact bytes to upload to the request's `code_artifact_uri`.
655    ///
656    /// The upload is the caller's: S3 is not in this crate's dependency set.
657    pub fn build_artifact_for(&self, request: &CreateImageRequest) -> Result<Vec<u8>, Error> {
658        self.control.build_artifact_for(request)
659    }
660
661    /// Every local guard [`Self::build_image`] runs, callable **before** the artifact upload.
662    ///
663    /// A delegation to [`ControlPlane::preflight`], on this type so the CLI's build path
664    /// reaches it through the one sandbox it already holds. Local; zero calls. The point is
665    /// ordering: `build_image`'s guards run after the caller's upload, so a request this
666    /// client itself refuses would still cost the S3 PUT unless the caller checks first.
667    pub fn preflight(&self, request: &CreateImageRequest) -> Result<(), Error> {
668        self.control.preflight(request)
669    }
670
671    /// The ARN for `identifier`: an ARN passes through with zero calls, a bare name is
672    /// resolved through the image listing by exact match.
673    ///
674    /// A read-only delegation to [`ControlPlane::resolve_image_arn`], on this type so the
675    /// CLI's launch path reaches it through the one sandbox it already holds. It touches
676    /// none of the five state-machine variables — resolution is a question about the
677    /// account, not about this VM's lifecycle.
678    pub async fn resolve_image_arn(&self, identifier: &str) -> Result<String, Error> {
679        self.control.resolve_image_arn(identifier).await
680    }
681
682    /// The image with exactly `name`, or `None`. Read-only; see
683    /// [`ControlPlane::find_image_by_name`] for the pagination and exact-match rules.
684    pub async fn find_image_by_name(
685        &self,
686        name: &str,
687    ) -> Result<Option<crate::control::ops::MicrovmImageSummaryWire>, Error> {
688        self.control.find_image_by_name(name).await
689    }
690
691    /// The content hash `build --reuse` keys an image name to. Local; zero calls.
692    pub fn artifact_content_hash_for(&self, request: &CreateImageRequest) -> String {
693        self.control.artifact_content_hash_for(request)
694    }
695
696    /// The versions of a managed base image, for pinning
697    /// [`CreateImageRequest::base_image_version`].
698    ///
699    /// A read-only delegation to [`crate::control::ControlPlane::managed_base_versions`], on
700    /// this type for the reason [`Self::resolve_image_arn`] gives: it touches none of the five
701    /// state-machine variables, because a question about AWS's published bases is not a
702    /// question about this VM's lifecycle.
703    pub async fn managed_base_versions(
704        &self,
705        base_image_arn: &str,
706    ) -> Result<Vec<crate::control::ops::ManagedMicrovmImageVersionWire>, Error> {
707        self.control.managed_base_versions(base_image_arn).await
708    }
709
710    /// One version's configuration, state, and availability status. Read-only.
711    pub async fn image_version(
712        &self,
713        identifier: &str,
714        version: &str,
715    ) -> Result<crate::control::ops::MicrovmImageVersionSummaryWire, Error> {
716        self.control.get_image_version(identifier, version).await
717    }
718
719    // ── run (STATE-1, STATE-2, STATE-3) ──────────────────────────────────────
720
721    /// Launches a MicroVM, waits for RUNNING, and returns its session.
722    ///
723    /// # The three state requirements this is
724    ///
725    /// STATE-1: the accepted launch moves the lifecycle to PENDING and records the image as
726    /// existing. STATE-2: the platform reporting RUNNING is what marks the token installed
727    /// — not the launch call, because the run hook is what delivers it and a launch that
728    /// dies during startup delivered nothing. STATE-3: `bootstrap_count` is incremented
729    /// exactly here, and a second `run` on the same sandbox is refused, which is what makes
730    /// "at most once per VM lifetime" a property of the type rather than of a caller's
731    /// discipline.
732    ///
733    /// The agent token rides in `runHookPayload`, which is what keeps it out of the shared
734    /// image snapshot. That is safe because the platform forwards no external traffic until
735    /// the run hook returns 200, so a per-VM secret delivered at launch wins the
736    /// first-writer race through the endpoint.
737    pub async fn run(&mut self, request: RunRequest) -> Result<&mut Session, Error> {
738        // STATE-3's local half. A sandbox that has already bootstrapped cannot bootstrap
739        // again, and the refusal is here rather than in a comment because `run` twice is
740        // the plausible mistake — a retry loop around a launch that timed out.
741        if self.bootstrap_count > 0 || self.microvm.is_some() {
742            return Err(Error::invalid_arg(format!(
743                "this sandbox has already launched a VM ({} bootstrap(s), lifecycle {}), and the \
744                 agent token is installed at most once per VM lifetime (STATE-3). A second VM \
745                 needs a second Sandbox — reusing this one would either re-deliver a run-hook \
746                 payload to a daemon whose one-shot bootstrap refuses it, or silently address \
747                 two guests through one handle.",
748                self.bootstrap_count, self.lifecycle,
749            )));
750        }
751
752        let Some(identifier) = request
753            .image_identifier
754            .clone()
755            .or_else(|| self.image.as_ref().map(|image| image.identifier.clone()))
756        else {
757            return Err(Error::new(
758                ErrorKind::Precondition,
759                "no image to launch: pass RunRequest::with_image or call build_image first."
760                    .to_string(),
761            ));
762        };
763
764        let agent_token = request.agent_token.clone().unwrap_or_else(mint_agent_token);
765        // Generated before the payload because the payload carries its two public-facing
766        // fields. The VM's secret half lives exactly as long as this binding: `keep()` below
767        // drops it before the launch call returns.
768        let launch_identity = if request.identity {
769            Some(crate::identity::LaunchIdentity::generate()?)
770        } else {
771            None
772        };
773        // Checked even though this builds the JSON itself, because neither half is ours:
774        // the token may be caller-supplied, so someone passing a signed blob rather than a
775        // bearer token is exactly who this catches (TRAP-5), and the launch env is entirely
776        // the caller's. This is the pre-flight refusal — over-ceiling fails here with the
777        // byte count, before the launch, rather than as a `ValidationException` on a member
778        // the caller did not know they were filling.
779        let payload = RunHookPayload::for_launch_with_identity(
780            &agent_token,
781            &request.launch_env,
782            launch_identity.as_ref(),
783        )?;
784
785        let mut wire = RunMicrovmRequest::new(&identifier, payload);
786        wire.image_version = request.image_version.clone();
787        wire.execution_role_arn = request.execution_role_arn.clone();
788        wire.max_idle_sec = request.max_idle_sec;
789        wire.suspended_sec = request.suspended_sec;
790        wire.auto_resume = request.auto_resume;
791        wire.max_duration_sec = request.max_duration_sec;
792        wire.token_scope = request.token_scope.clone();
793        if request.egress {
794            wire = wire.with_egress();
795        }
796        if request.shell {
797            wire = wire.with_shell();
798        }
799
800        let launched = self.control.run_microvm(wire).await?;
801
802        // The durable half of the identity, kept the moment the launch is accepted: the host
803        // secret and the VM's public pin. The VM's own secret ends here — `keep` drops it —
804        // so from this line on the host can verify its VM and impersonate it never.
805        self.tunnel_identity = launch_identity.map(crate::identity::LaunchIdentity::keep);
806
807        // STATE-1: the launch was accepted.
808        self.lifecycle = Lifecycle::Pending;
809        self.image_exists = true;
810        // Recorded here rather than after the wait, because the window the idlePolicy
811        // enforces was set by *this* request and a launch that then fails still leaves a VM
812        // the caller may have to reason about.
813        self.suspended_window = Some(Duration::from_secs(u64::from(request.suspended_sec)));
814        let id = launched.id.clone();
815        self.microvm = Some(launched);
816
817        let running = self
818            .control
819            .wait_for_running(
820                &id,
821                WaitOpts {
822                    timeout: request.ready_timeout,
823                    poll_interval: LIFECYCLE_POLL_INTERVAL,
824                    stall_grace: Duration::MAX,
825                },
826            )
827            .await?;
828
829        // STATE-2. The platform reported the run hook succeeded, so the token is in the
830        // guest's memory — and this is the one place that counts it (STATE-3).
831        self.lifecycle = Lifecycle::Running;
832        self.token_installed = true;
833        self.bootstrap_count += 1;
834        let endpoint = running.endpoint.clone();
835        self.microvm = Some(running);
836
837        let minter = Arc::new(ControlPlaneMinter {
838            control: Arc::clone(&self.control),
839            microvm_id: id,
840        });
841        let mut builder = Session::builder(endpoint, agent_token)
842            .with_minter(minter)
843            .with_port(self.control.port());
844        if let Some(backend) = &self.session_backend {
845            builder = builder.with_backend(Arc::clone(backend));
846        }
847        self.session = Some(builder.build()?);
848        Ok(self.session.as_mut().expect("just assigned"))
849    }
850
851    // ── suspend (STATE-4, STATE-5, STATE-6) ──────────────────────────────────
852
853    /// Freezes the VM and waits for the platform to report it.
854    ///
855    /// A freeze and restore rather than a stop and start: the guest keeps its memory, so the
856    /// token, the filesystem, and every exec record survive. The one thing that does not is
857    /// the guest's view of time — it observes the whole suspension as a single jump, so any
858    /// timeout, lease, or TLS session a running command holds expires at once on resume.
859    ///
860    /// # STATE-5 is checked before the wire, not after
861    ///
862    /// A suspend from anything but RUNNING is refused here with **zero** control-plane
863    /// calls. That is the observable difference between this and a client that lets AWS
864    /// answer, and it is what the test asserts on.
865    pub async fn suspend(&mut self) -> Result<(), Error> {
866        let id = self.require_microvm("suspend")?;
867
868        // STATE-5.
869        if self.lifecycle != Lifecycle::Running {
870            return Err(Error::invalid_arg(format!(
871                "microvm {id} is {} and a suspend is only issued from RUNNING (STATE-5). Refused \
872                 here rather than by the service, because the service's answer about a \
873                 non-running id does not say which of the two things went wrong — and a suspend \
874                 issued from SUSPENDED is a caller who believes they resumed.",
875                self.lifecycle,
876            )));
877        }
878
879        // STATE-4: accepted, so the lifecycle moves before the wait. Acceptance is the
880        // wire call succeeding, so the assignment comes after it: moving to SUSPENDING
881        // first would leave a failed call (a throttle, a dead transport) stuck in a state
882        // neither suspend nor resume accepts, bricking the handle over one bad request.
883        self.control.suspend(&id).await?;
884        self.lifecycle = Lifecycle::Suspending;
885        // Stamped after the call and before the wait, not after the wait: the idlePolicy's
886        // window starts when the platform begins suspending, so timing it from SUSPENDED
887        // would under-count the transition and call a closed window open.
888        self.suspended_at = Some(self.control.clock().elapsed());
889
890        let settled = self
891            .control
892            .wait_for_state(
893                &id,
894                &crate::control::microvm::SUSPEND_WANTED,
895                &[],
896                self.lifecycle_wait(),
897            )
898            .await?;
899
900        // STATE-6, and the TERMINATED case beside it. A VM that dies while suspending is a
901        // state to report rather than an exception out of the middle of a teardown, so the
902        // wait *wants* TERMINATED — and recording it here is what stops a resume from being
903        // offered afterwards (STATE-11).
904        self.lifecycle = match settled.state.as_str() {
905            "SUSPENDED" => Lifecycle::Suspended,
906            "TERMINATED" => {
907                self.was_terminated = true;
908                Lifecycle::Terminated
909            }
910            other => {
911                return Err(Error::new(
912                    ErrorKind::Platform,
913                    format!(
914                        "the suspend wait returned {other}, which is neither SUSPENDED nor \
915                         TERMINATED — and those two are what this client asked for."
916                    ),
917                ));
918            }
919        };
920        self.microvm = Some(settled);
921        Ok(())
922    }
923
924    // ── resume (STATE-7, STATE-8, STATE-12) ──────────────────────────────────
925
926    /// Thaws the VM and returns a usable session.
927    ///
928    /// # What is deliberately not re-delivered (STATE-7)
929    ///
930    /// Nothing. No run-hook payload, no token, no bootstrap. The in-memory token survived
931    /// the freeze, and re-delivering it would hit the daemon's one-shot bootstrap and be
932    /// refused — a 409 that reads like a broken VM.
933    ///
934    /// # The window is checked first (STATE-12)
935    ///
936    /// Before any wire call, because the answer is already known and calling costs the poll
937    /// timeout to learn something worse. The falsification is a service reporting
938    /// TERMINATED: without this check the resume burns the full deadline and then reports a
939    /// state the client could have named at once.
940    ///
941    /// # The proxy token is dropped (STATE-8)
942    ///
943    /// [`Session::rebind`] invalidates it. The endpoint URL does not change across
944    /// suspend/resume, so the rebind is usually a no-op on the URL — but a token minted
945    /// against the pre-suspend instance may no longer validate, and that rejection reads
946    /// exactly like a dead daemon.
947    pub async fn resume(&mut self) -> Result<&mut Session, Error> {
948        let id = self.require_microvm("resume")?;
949
950        // STATE-11's local half: a terminated VM never returns to RUNNING, so the refusal
951        // comes before the window check and before any call.
952        if self.was_terminated || self.lifecycle == Lifecycle::Terminated {
953            return Err(Error::invalid_arg(format!(
954                "microvm {id} was terminated, and a terminated VM never returns to RUNNING \
955                 (STATE-11). There is nothing to resume: the guest's memory is gone, so even a \
956                 call the service accepted would hand back a different machine."
957            )));
958        }
959        if self.lifecycle != Lifecycle::Suspended {
960            return Err(Error::invalid_arg(format!(
961                "microvm {id} is {} and a resume is only issued from SUSPENDED (STATE-7).",
962                self.lifecycle,
963            )));
964        }
965
966        // STATE-12, first and locally.
967        self.require_open_suspended_window(&id)?;
968
969        self.control.resume(&id).await?;
970        // `fail_on` is the *dead* states rather than the terminal ones: SUSPENDED is the
971        // state this call was made from, so failing on it would fail every resume. A VM the
972        // idlePolicy terminated during suspension never reaches RUNNING, and waiting only
973        // for RUNNING there burns the full timeout and then reports a timeout message
974        // hiding a cause the service had already stated in `stateReason`.
975        let running = self
976            .control
977            .wait_for_state(
978                &id,
979                &["RUNNING"],
980                &crate::constants::DEAD_STATES,
981                self.lifecycle_wait(),
982            )
983            .await?;
984
985        self.lifecycle = Lifecycle::Running;
986        // STATE-8, through the endpoint the service just reported rather than the one held:
987        // the URL is measured not to change, and reading it from the response is what makes
988        // that a fact this code depends on rather than an assumption it encodes.
989        let endpoint = running.endpoint.clone();
990        self.microvm = Some(running);
991        if let Some(session) = self.session.as_mut() {
992            session.rebind(endpoint);
993        }
994        // Cleared on success so the next cycle's window is measured from the next suspend.
995        // Leaving it set would accumulate every suspension's elapsed time into one total and
996        // reject a resume whose own window is wide open.
997        self.suspended_at = None;
998
999        self.session.as_mut().ok_or_else(|| {
1000            Error::new(
1001                ErrorKind::Unexpected,
1002                "the VM resumed but this sandbox holds no session, which run() always builds"
1003                    .to_string(),
1004            )
1005        })
1006    }
1007
1008    /// Rejects a resume the launch-time `idlePolicy` has already made impossible (STATE-12).
1009    ///
1010    /// The message names the elapsed time, the window, and the `idlePolicy` finding, because
1011    /// "cannot resume" alone sends a reader looking for the flag that reopens it.
1012    fn require_open_suspended_window(&self, id: &str) -> Result<(), Error> {
1013        let (Some(window), Some(since)) = (self.suspended_window, self.suspended_at) else {
1014            // No window recorded means this sandbox did not send the launch — the attach
1015            // path — and guessing a default would refuse a resume the service would honour.
1016            return Ok(());
1017        };
1018        let elapsed = self.control.clock().elapsed().saturating_sub(since);
1019        if elapsed <= window {
1020            return Ok(());
1021        }
1022        Err(Error::new(
1023            ErrorKind::WindowClosed,
1024            format!(
1025                "microvm {id} has been suspended {}s, past the {}s suspendedDurationSeconds \
1026                 window set at launch — the idlePolicy terminates a suspended VM once that window \
1027                 passes, so there is nothing left to resume (docs/PLATFORM.md, '`idlePolicy`'). \
1028                 Refused before ResumeMicrovm because suspendedDurationSeconds exists only in the \
1029                 RunMicrovm request: GetMicrovm does not return it, so this client is the only \
1030                 party that can name the number. A longer window has to be set at launch on the \
1031                 next VM; there is no call that extends this one.",
1032                elapsed.as_secs(),
1033                window.as_secs(),
1034            ),
1035        ))
1036    }
1037
1038    // ── terminate (STATE-9, STATE-10) ────────────────────────────────────────
1039
1040    /// Tears down, best-effort, never erroring.
1041    ///
1042    /// Order: VM, then image, then the log group **last**. The log group is last because the
1043    /// service can recreate a group deleted before its image — and see
1044    /// [`TeardownReport::undeleted`] for why this crate names it rather than deleting it.
1045    pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
1046        let mut report = TeardownReport::default();
1047        self.torn_down = true;
1048
1049        // The session first: it holds a cached proxy token whose only remaining use would be
1050        // a request against a VM that is going away.
1051        self.session = None;
1052
1053        // 1. The VM.
1054        if let Some(id) = self.microvm.as_ref().map(|vm| vm.id.clone()) {
1055            // STATE-9. Recorded before the call, so a terminate whose call fails still marks
1056            // the VM as one this client asked to destroy — which is what stops a later
1057            // resume (STATE-11) rather than leaving the sandbox looking resumable.
1058            self.lifecycle = Lifecycle::Terminating;
1059            self.was_terminated = true;
1060
1061            match self.control.terminate(&id).await {
1062                Ok(()) => report.terminate_accepted = true,
1063                Err(error) => {
1064                    report.failures.push(format!("terminate {id}: {error}"));
1065                    report.undeleted.push(id.clone());
1066                }
1067            }
1068
1069            if report.terminate_accepted
1070                && let Some(timeout) = opts.wait_for_terminated
1071            {
1072                let wait = WaitOpts {
1073                    timeout,
1074                    poll_interval: LIFECYCLE_POLL_INTERVAL,
1075                    stall_grace: Duration::MAX,
1076                };
1077                match self
1078                    .control
1079                    .wait_for_state(&id, &["TERMINATED"], &[], wait)
1080                    .await
1081                {
1082                    // STATE-10.
1083                    Ok(settled) => {
1084                        self.lifecycle = Lifecycle::Terminated;
1085                        self.microvm = Some(settled);
1086                    }
1087                    // Not a leak: the platform accepted the terminate, so the VM is on its
1088                    // way out and the lifecycle stays TERMINATING honestly.
1089                    Err(error) => report
1090                        .failures
1091                        .push(format!("waiting for {id} to reach TERMINATED: {error}")),
1092                }
1093            }
1094        }
1095
1096        // 2. The image, retrying — an image in CREATING refuses deletion and a VM still
1097        //    terminating holds a reference.
1098        if opts.delete_image {
1099            match self.image.as_ref().map(|image| image.identifier.clone()) {
1100                Some(identifier) => {
1101                    let deleted = self
1102                        .control
1103                        .delete_image(&identifier, opts.delete_attempts, opts.delete_backoff)
1104                        .await;
1105                    report.image_deleted = Some(deleted);
1106                    if deleted {
1107                        self.image_exists = false;
1108                    } else {
1109                        report.failures.push(format!(
1110                            "the image {identifier} survived {} delete attempts",
1111                            opts.delete_attempts.max(1)
1112                        ));
1113                        report.undeleted.push(identifier);
1114                    }
1115                }
1116                None => report.image_deleted = Some(false),
1117            }
1118        }
1119
1120        // 3. The log group, LAST. Named rather than deleted; see TeardownReport::undeleted.
1121        if opts.delete_log_group
1122            && let Some(group) = self.image.as_ref().map(Image::build_log_group)
1123        {
1124            report.failures.push(format!(
1125                "the build log group {group} was not deleted: CloudWatch Logs is not in this \
1126                 crate's dependency set, so it is reported rather than removed. It is \
1127                 service-created, which means no Terraform stack owns it and `terraform destroy` \
1128                 leaves it behind — six accumulated before anyone noticed."
1129            ));
1130            report.undeleted.push(group);
1131        }
1132
1133        report.lifecycle = Some(self.lifecycle);
1134        report
1135    }
1136
1137    // ── internals ────────────────────────────────────────────────────────────
1138
1139    /// The VM id, or a precondition error naming what was attempted.
1140    fn require_microvm(&self, what: &str) -> Result<String, Error> {
1141        self.microvm
1142            .as_ref()
1143            .map(|vm| vm.id.clone())
1144            .ok_or_else(|| {
1145                Error::new(
1146                    ErrorKind::Precondition,
1147                    format!("nothing to {what}: this sandbox has not launched a VM."),
1148                )
1149            })
1150    }
1151
1152    /// Five minutes at five-second polls, for suspend, resume, and terminate.
1153    fn lifecycle_wait(&self) -> WaitOpts {
1154        WaitOpts {
1155            timeout: DEFAULT_LIFECYCLE_TIMEOUT,
1156            poll_interval: LIFECYCLE_POLL_INTERVAL,
1157            stall_grace: Duration::MAX,
1158        }
1159    }
1160}
1161
1162/// Warns about a live VM rather than tearing it down.
1163///
1164/// See the module docs: `Drop` cannot await, so a teardown here would either deadlock
1165/// inside a runtime or race the process exit. The warning names the id, because the only
1166/// useful thing a drop can do is tell whoever reads stderr what to go delete.
1167///
1168/// `eprintln!` rather than a log macro because this crate has no logging dependency, and
1169/// taking one on to warn about a leak would be a dependency for a diagnostic.
1170impl Drop for Sandbox {
1171    fn drop(&mut self) {
1172        if self.torn_down {
1173            return;
1174        }
1175        if let Some(vm) = self.microvm.as_ref()
1176            && self.lifecycle.is_live()
1177        {
1178            eprintln!(
1179                "warning: the Sandbox for microvm {} was dropped in {} without terminate(). \
1180                 Nothing was torn down — Drop cannot await, so a teardown here would deadlock \
1181                 inside a runtime. The VM bills until its maximumDurationInSeconds ceiling: \
1182                 terminate it with `microvm terminate {}`.",
1183                vm.id, self.lifecycle, vm.id,
1184            );
1185        }
1186    }
1187}
1188
1189/// A fresh per-VM bearer token: 32 bytes of randomness as 64 hex characters.
1190///
1191/// `getrandom` reaches the kernel CSPRNG directly. The hand-rolled `/dev/urandom`
1192/// read this replaces carried a clock-mixing fallback for a failed read; getrandom
1193/// fails only when the OS pool is genuinely unavailable, and minting a bearer token
1194/// from a clock in that state would be worse than refusing the launch.
1195fn mint_agent_token() -> String {
1196    let mut bytes = [0u8; 32];
1197    getrandom::fill(&mut bytes).expect("the OS random pool is available");
1198    const_hex::encode(bytes)
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use crate::control::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1205    use crate::control::transport::Method;
1206
1207    /// A sandbox over the contract recorder, plus the two handles a test asserts through.
1208    ///
1209    /// The recorder is T-W2-4's, deliberately: its answers are literal JSON in the service
1210    /// model's spelling rather than values serialized from this crate's types, so a member
1211    /// this lane misreads cannot be misread identically by the fake.
1212    fn planted() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1213        let recorder = Arc::new(FakeControlPlane::new());
1214        let clock = Arc::new(TestClock::new());
1215        let plane = ControlPlane::with_transport(
1216            Arc::clone(&recorder) as Arc<dyn crate::control::transport::Transport>,
1217            Region::UsEast1,
1218            Arc::clone(&clock) as Arc<dyn crate::control::Clock>,
1219        );
1220        (Sandbox::with_control_plane(plane), recorder, clock)
1221    }
1222
1223    /// Queues everything a launch to RUNNING needs.
1224    fn answer_launch(recorder: &FakeControlPlane) {
1225        recorder
1226            .answer(
1227                "RunMicrovm",
1228                Answer::ok(fake::microvm_response("PENDING", None)),
1229            )
1230            .answer(
1231                "GetMicrovm",
1232                Answer::ok(fake::microvm_response("RUNNING", None)),
1233            )
1234            .answer(
1235                "CreateMicrovmAuthToken",
1236                Answer::ok(fake::auth_token_response("proxy-token")),
1237            );
1238    }
1239
1240    /// A launched sandbox in RUNNING, which is where four of the twelve keys start.
1241    async fn launched() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1242        let (mut sandbox, recorder, clock) = planted();
1243        answer_launch(&recorder);
1244        sandbox
1245            .run(RunRequest::new().with_image("arn:image"))
1246            .await
1247            .expect("the launch reaches RUNNING");
1248        (sandbox, recorder, clock)
1249    }
1250
1251    /// Drives a launched sandbox to SUSPENDED, which is where resume starts.
1252    ///
1253    /// Both `GetMicrovm` answers are queued **before** the launch rather than one before
1254    /// each wait, and that is not a style choice. The recorder repeats its last queued
1255    /// answer, so a queue left at `[RUNNING]` after the launch and then appended to becomes
1256    /// `[RUNNING, SUSPENDED]` — the suspend wait pops the stale RUNNING, sleeps a poll
1257    /// interval, and the fake's `sleep` **advances the clock**. That five seconds is
1258    /// invisible until it lands in the middle of the STATE-12 window arithmetic, which is
1259    /// exactly where it first showed up. Queueing both up front means every wait matches on
1260    /// its first poll and no test clock moves except when a test moves it.
1261    async fn suspended_with_window(
1262        suspended_sec: u32,
1263    ) -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1264        let (mut sandbox, recorder, clock) = planted();
1265        recorder
1266            .answer(
1267                "RunMicrovm",
1268                Answer::ok(fake::microvm_response("PENDING", None)),
1269            )
1270            .answer(
1271                "GetMicrovm",
1272                Answer::ok(fake::microvm_response("RUNNING", None)),
1273            )
1274            .answer(
1275                "GetMicrovm",
1276                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1277            )
1278            .answer(
1279                "CreateMicrovmAuthToken",
1280                Answer::ok(fake::auth_token_response("proxy-token")),
1281            )
1282            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1283
1284        sandbox
1285            .run(
1286                RunRequest::new()
1287                    .with_image("arn:image")
1288                    .with_suspended_sec(suspended_sec),
1289            )
1290            .await
1291            .expect("launches");
1292        sandbox.suspend().await.expect("suspends");
1293        assert_eq!(
1294            clock.now(),
1295            Duration::ZERO,
1296            "no wait may have slept, or the window arithmetic below is measuring the fake's \
1297             poll interval as well as the elapsed suspension"
1298        );
1299        (sandbox, recorder, clock)
1300    }
1301
1302    /// A suspended sandbox at the default ten-minute window.
1303    async fn suspended() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1304        suspended_with_window(600).await
1305    }
1306
1307    /// **STATE-1 and STATE-2.** A launch accepted is PENDING with the image recorded as
1308    /// existing; the platform reporting the run hook succeeded is what marks the token
1309    /// installed.
1310    ///
1311    /// The two halves are asserted with the *same* fake because they are the same call, and
1312    /// what distinguishes them is which fact is recorded when: `image_exists` is true from
1313    /// the accepted launch, `token_installed` only from the RUNNING report. A client that
1314    /// set both at the launch call would pass an end-state assertion and be wrong about a
1315    /// VM that died during startup — which is the next test.
1316    #[tokio::test]
1317    async fn a_launch_records_the_image_and_the_running_report_installs_the_token() {
1318        let (mut sandbox, recorder, _) = planted();
1319        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1320        assert!(
1321            !sandbox.image_exists(),
1322            "nothing is recorded before a launch"
1323        );
1324        assert!(!sandbox.token_installed());
1325
1326        answer_launch(&recorder);
1327        let session = sandbox
1328            .run(RunRequest::new().with_image("arn:image"))
1329            .await
1330            .expect("launches");
1331        assert_eq!(
1332            session.endpoint(),
1333            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1334        );
1335
1336        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1337        assert!(sandbox.image_exists(), "STATE-1: the image is recorded");
1338        assert!(sandbox.token_installed(), "STATE-2: the token is installed");
1339        assert_eq!(sandbox.bootstrap_count(), 1);
1340        assert_eq!(sandbox.microvm().expect("launched").id, "mvm-abc123");
1341
1342        // The payload really carried the token, read off the wire member rather than from
1343        // the request type — a field on a struct proves nothing about what was emitted.
1344        let body = recorder.first_body("RunMicrovm");
1345        let payload = body["runHookPayload"].as_str().expect("a string");
1346        assert!(payload.starts_with(r#"{"agent_token":"#), "{payload}");
1347        assert_eq!(body["idlePolicy"]["suspendedDurationSeconds"], 600);
1348    }
1349
1350    /// A launch env reaches the wire member the daemon parses, and it is the only thing
1351    /// that changes about the request.
1352    ///
1353    /// Read off `runHookPayload` rather than off the request struct: a field on a struct
1354    /// proves nothing about what was emitted, which is the same argument the test above
1355    /// makes about the token.
1356    #[tokio::test]
1357    async fn a_launch_env_reaches_the_run_hook_payload_on_the_wire() {
1358        let (mut sandbox, recorder, _) = planted();
1359        answer_launch(&recorder);
1360        sandbox
1361            .run(
1362                RunRequest::new()
1363                    .with_image("arn:image")
1364                    .with_launch_env("ANTHROPIC_BASE_URL", "https://gateway.example")
1365                    .with_launch_env("PATH", "/usr/local/bin:/usr/bin:/bin"),
1366            )
1367            .await
1368            .expect("launches");
1369
1370        let body = recorder.first_body("RunMicrovm");
1371        let payload = body["runHookPayload"].as_str().expect("a string");
1372        // One parse deeper, because that is where the daemon reads it from too.
1373        let inner: serde_json::Value =
1374            serde_json::from_str(payload).expect("the payload is itself JSON");
1375        assert!(
1376            inner["agent_token"].as_str().is_some_and(|t| !t.is_empty()),
1377            "the token still rides alongside: {payload}"
1378        );
1379        assert_eq!(
1380            inner["env"]["ANTHROPIC_BASE_URL"],
1381            "https://gateway.example"
1382        );
1383        assert_eq!(inner["env"]["PATH"], "/usr/local/bin:/usr/bin:/bin");
1384    }
1385
1386    /// **A pinned `imageVersion` reaches the wire through the sandbox**, and an unpinned
1387    /// launch omits the member entirely.
1388    ///
1389    /// This is the layer a caller actually uses, so the forwarding is what needs asserting:
1390    /// [`RunRequest::image_version`] is a field on a struct and proves nothing about the
1391    /// emitted body until it is read off the wire member.
1392    ///
1393    /// **Falsification** — run 2026-08-16. Drop `wire.image_version = ...` from `run` and the
1394    /// pinned assertion goes red with the member absent, while every other launch test still
1395    /// passes — which is why this one is here.
1396    #[tokio::test]
1397    async fn a_pinned_image_version_is_forwarded_to_the_launch_request() {
1398        let (mut sandbox, recorder, _) = planted();
1399        answer_launch(&recorder);
1400        sandbox
1401            .run(
1402                RunRequest::new()
1403                    .with_image("arn:image")
1404                    .with_image_version("2.0"),
1405            )
1406            .await
1407            .expect("launches");
1408        assert_eq!(
1409            recorder.first_body("RunMicrovm")["imageVersion"],
1410            "2.0",
1411            "a canary has to launch against the version it means to test"
1412        );
1413
1414        let (mut sandbox, recorder, _) = planted();
1415        answer_launch(&recorder);
1416        sandbox
1417            .run(RunRequest::new().with_image("arn:image"))
1418            .await
1419            .expect("launches");
1420        assert!(
1421            recorder
1422                .first_body("RunMicrovm")
1423                .get("imageVersion")
1424                .is_none(),
1425            "an unpinned launch sends what this client always sent"
1426        );
1427    }
1428
1429    /// **The local refusal, with zero control-plane calls.**
1430    ///
1431    /// The observable difference between this client and one that lets AWS answer, and the
1432    /// same shape as STATE-5's suspend refusal: an over-budget launch env fails before the
1433    /// launch rather than as a `ValidationException` on a member the caller did not know
1434    /// they were filling. botocore does not enforce the ceiling client-side, so without
1435    /// this there is no local signal at all.
1436    ///
1437    /// **Falsification** — build the payload after `run_microvm` instead of before, and the
1438    /// call count assertion goes red.
1439    #[tokio::test]
1440    async fn an_over_budget_launch_env_is_refused_before_any_control_plane_call() {
1441        let (mut sandbox, recorder, _) = planted();
1442        answer_launch(&recorder);
1443
1444        let error = sandbox
1445            .run(
1446                RunRequest::new()
1447                    .with_image("arn:image")
1448                    .with_launch_env("AWS_SESSION_TOKEN", "t".repeat(4096)),
1449            )
1450            .await
1451            .expect_err("a credential-scale launch env does not fit the payload");
1452
1453        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1454        let message = error.to_string();
1455        assert!(message.contains("ceiling of 4096"), "{message}");
1456        assert!(message.contains("launch env contributed"), "{message}");
1457        assert_eq!(
1458            recorder.calls().len(),
1459            0,
1460            "the refusal has to be local: a launch that reached AWS costs a VM and a bill"
1461        );
1462        // And the sandbox is left usable rather than half-launched, so a caller can trim
1463        // the env and retry through the same handle.
1464        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1465        assert_eq!(sandbox.bootstrap_count(), 0);
1466        assert!(sandbox.microvm().is_none());
1467    }
1468
1469    /// **Issue #24's guards reach through `Sandbox`, which is the layer both bindings use.**
1470    ///
1471    /// `microvms-py`'s `run` and `microvms-js`'s `run` both build a [`RunRequest`] and hand it to
1472    /// [`Sandbox::run`], which fills a `RunMicrovmRequest` and calls
1473    /// [`crate::control::ControlPlane::run_microvm`]. So the guards there cover the bindings — but
1474    /// "cover" is a claim about a call chain, and a chain is what a refactor breaks. This asserts
1475    /// it at the layer the bindings actually enter, so a `Sandbox::run` that stopped delegating
1476    /// (or a binding-side default that bypassed the request type) fails a test rather than shipping
1477    /// an unguarded surface to Python and Node.
1478    ///
1479    /// `max_idle_sec` is the case that matters most here, because it is a plain `u32` keyword
1480    /// argument on both bindings with no type narrowing it: `sandbox.run(max_idle_sec=59)` is what
1481    /// a Python caller writes, and before this it reached the wire.
1482    ///
1483    /// **Guard proof.** Delete `require_idle_duration` from `run_microvm` and the idle row goes red
1484    /// on the call count; delete `require_valid_role_arn` and the role row does. Neither guard is in
1485    /// this file, which is the point — the test is about the chain.
1486    #[tokio::test]
1487    async fn the_bindings_layer_refuses_an_illegal_launch_with_zero_calls() {
1488        let cases: [(&str, RunRequest, &str); 3] = [
1489            (
1490                "max_idle_sec=59, which is what a Python keyword argument passes",
1491                RunRequest {
1492                    max_idle_sec: 59,
1493                    ..RunRequest::new().with_image("arn:image")
1494                },
1495                "maxIdleDurationSeconds",
1496            ),
1497            (
1498                "an execution role that is a bare name",
1499                RunRequest {
1500                    execution_role_arn: Some("execution-role".to_string()),
1501                    ..RunRequest::new().with_image("arn:image")
1502                },
1503                "role *name*",
1504            ),
1505            (
1506                "a pinned version with a trailing newline",
1507                RunRequest::new()
1508                    .with_image("arn:image")
1509                    .with_image_version("2.0\n"),
1510                "contains whitespace",
1511            ),
1512        ];
1513
1514        for (label, request, expected) in cases {
1515            let (mut sandbox, recorder, _) = planted();
1516            // The launch answered, so a missing guard is a *successful* launch — a real VM and a
1517            // real bill — rather than a different failure.
1518            answer_launch(&recorder);
1519
1520            // `expect_err` needs the Ok type to be `Debug`, and `run` answers `&mut Session`
1521            // (which is not) — so the discriminant is matched instead. That is also the more
1522            // honest assertion: it says the launch produced no session at all.
1523            let Err(error) = sandbox.run(request).await else {
1524                panic!("{label} must be refused");
1525            };
1526            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1527            assert!(
1528                error.to_string().contains(expected),
1529                "{label}: wanted {expected:?}, got {error}"
1530            );
1531            assert_eq!(
1532                recorder.calls().len(),
1533                0,
1534                "{label}: the refusal has to be local, because a launch that reached AWS costs a \
1535                 VM and a bill"
1536            );
1537            assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1538            assert!(sandbox.microvm().is_none());
1539        }
1540    }
1541
1542    /// A launch whose VM dies during startup leaves the token **not** installed, because
1543    /// the run hook is what delivers it and a dead VM ran no hook.
1544    ///
1545    /// The distinction this pins is the one the test above cannot: both a correct client and
1546    /// one that installs the token at the launch call reach RUNNING with the token set, and
1547    /// only this case separates them.
1548    #[tokio::test]
1549    async fn a_launch_that_dies_during_startup_installs_no_token() {
1550        let (mut sandbox, recorder, _) = planted();
1551        recorder
1552            .answer(
1553                "RunMicrovm",
1554                Answer::ok(fake::microvm_response("PENDING", None)),
1555            )
1556            .answer(
1557                "GetMicrovm",
1558                Answer::ok(fake::microvm_response(
1559                    "TERMINATED",
1560                    Some("run hook returned 500"),
1561                )),
1562            );
1563
1564        let error = sandbox
1565            .run(RunRequest::new().with_image("arn:image"))
1566            .await
1567            .expect_err("a VM that died during startup is not a launch");
1568        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1569        assert!(
1570            error.to_string().contains("run hook returned 500"),
1571            "{error}"
1572        );
1573
1574        assert!(
1575            !sandbox.token_installed(),
1576            "the run hook never answered, so nothing was installed"
1577        );
1578        assert_eq!(
1579            sandbox.bootstrap_count(),
1580            0,
1581            "a bootstrap counted here would make STATE-3's ceiling unreachable for a retry"
1582        );
1583        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1584        assert!(
1585            sandbox.image_exists(),
1586            "STATE-1 still holds: the launch was accepted against a real image"
1587        );
1588    }
1589
1590    /// **STATE-3, the guard proof.** A second `run` on one sandbox is refused, and it is
1591    /// refused with **zero** further control-plane calls.
1592    ///
1593    /// The call count is the assertion that matters. A client that let the second launch
1594    /// through would create a second VM and silently address two guests through one handle,
1595    /// and it would also deliver a second run-hook payload to a daemon whose one-shot
1596    /// bootstrap answers 409 — so "an error came back" is not enough to tell the two apart.
1597    ///
1598    /// **Falsification** — delete the `bootstrap_count > 0` branch from `run` and this test
1599    /// is red on both the count and the call total. Verified; see the packet's guard proofs.
1600    #[tokio::test]
1601    async fn a_second_run_on_one_sandbox_is_refused_before_any_call() {
1602        let (mut sandbox, recorder, _) = launched().await;
1603        assert_eq!(sandbox.bootstrap_count(), 1);
1604        let before = recorder.calls().len();
1605
1606        let error = sandbox
1607            .run(RunRequest::new().with_image("arn:image"))
1608            .await
1609            .expect_err("the token is installed at most once per VM lifetime");
1610        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1611        assert!(error.to_string().contains("STATE-3"), "{error}");
1612        assert!(
1613            error.to_string().contains("needs a second Sandbox"),
1614            "the remedy has to be nameable: {error}"
1615        );
1616
1617        assert_eq!(
1618            sandbox.bootstrap_count(),
1619            1,
1620            "bootstrap_count <= 1 is the Z3-proved invariant"
1621        );
1622        assert_eq!(
1623            recorder.calls().len(),
1624            before,
1625            "the refusal must cost no control-plane call"
1626        );
1627        assert_eq!(recorder.call_count("RunMicrovm"), 1);
1628    }
1629
1630    /// **STATE-4 and STATE-6.** A suspend from RUNNING moves to SUSPENDING before the wait,
1631    /// and the platform reporting SUSPENDED is what moves it to SUSPENDED.
1632    #[tokio::test]
1633    async fn a_suspend_from_running_passes_through_suspending_to_suspended() {
1634        let (sandbox, recorder, _) = suspended().await;
1635        assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1636        assert!(
1637            sandbox.token_installed(),
1638            "a freeze keeps the guest's memory, so the token survives"
1639        );
1640        assert_eq!(sandbox.bootstrap_count(), 1, "no re-bootstrap on a freeze");
1641
1642        let calls = recorder.calls();
1643        let suspend = calls
1644            .iter()
1645            .find(|call| call.operation == "SuspendMicrovm")
1646            .expect("the suspend went out");
1647        assert_eq!(suspend.method, Method::Post);
1648        assert_eq!(suspend.path, "/2025-09-09/microvms/mvm-abc123/suspend");
1649    }
1650
1651    /// A suspend whose VM dies while suspending reports TERMINATED rather than raising, and
1652    /// records it — which is what stops a later resume.
1653    ///
1654    /// TERMINATED is *wanted* by the suspend wait for a reason: a VM that dies mid-suspend
1655    /// is a state to report, not an exception out of the middle of a teardown.
1656    #[tokio::test]
1657    async fn a_vm_that_dies_while_suspending_is_recorded_rather_than_raised() {
1658        let (mut sandbox, recorder, _) = launched().await;
1659        recorder
1660            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1661            .answer(
1662                "GetMicrovm",
1663                Answer::ok(fake::microvm_response("TERMINATED", Some("idle policy"))),
1664            );
1665
1666        sandbox
1667            .suspend()
1668            .await
1669            .expect("a death mid-suspend is a state, not an error");
1670        assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
1671        assert!(
1672            sandbox.was_terminated(),
1673            "STATE-11's precondition is recorded"
1674        );
1675    }
1676
1677    /// A suspend whose wire call fails leaves the sandbox in RUNNING, and a retry works.
1678    ///
1679    /// STATE-4's "accepted" is the wire call succeeding. A client that moved to SUSPENDING
1680    /// before the call would strand a failed call there — a state neither suspend nor
1681    /// resume accepts, so one throttled or dropped request would brick the handle.
1682    /// **Falsification** — move `self.lifecycle = Lifecycle::Suspending` back above the
1683    /// `control.suspend` call and the first assertion here reads SUSPENDING, and the retry
1684    /// is refused by the STATE-5 guard.
1685    #[tokio::test]
1686    async fn a_suspend_whose_call_fails_stays_running_and_can_be_retried() {
1687        let (mut sandbox, recorder, _) = planted();
1688        recorder
1689            .answer(
1690                "RunMicrovm",
1691                Answer::ok(fake::microvm_response("PENDING", None)),
1692            )
1693            .answer(
1694                "GetMicrovm",
1695                Answer::ok(fake::microvm_response("RUNNING", None)),
1696            )
1697            .answer(
1698                "GetMicrovm",
1699                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1700            )
1701            .answer(
1702                "CreateMicrovmAuthToken",
1703                Answer::ok(fake::auth_token_response("proxy-token")),
1704            )
1705            // A 409 rather than a 429 or a transport cut, because those two are retried
1706            // inside `send_with_retry` and this test wants the call to fail once, fast.
1707            .answer("SuspendMicrovm", Answer::failure(409, "ConflictException"))
1708            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1709
1710        sandbox
1711            .run(RunRequest::new().with_image("arn:image"))
1712            .await
1713            .expect("launches");
1714
1715        sandbox
1716            .suspend()
1717            .await
1718            .expect_err("the control plane refused the suspend");
1719        assert_eq!(
1720            sandbox.lifecycle(),
1721            Lifecycle::Running,
1722            "a refused suspend must not move the lifecycle: SUSPENDING accepts neither a \
1723             suspend nor a resume, so recording it here would brick the handle"
1724        );
1725
1726        sandbox.suspend().await.expect("the retry suspends");
1727        assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1728        assert_eq!(recorder.call_count("SuspendMicrovm"), 2);
1729    }
1730
1731    /// **STATE-5, the guard proof.** A suspend from anything but RUNNING is refused with
1732    /// **zero** control-plane calls.
1733    ///
1734    /// Every non-RUNNING state, so a state added to `Lifecycle` later is not silently
1735    /// exempt. The count is the load-bearing assertion: letting the call go and reading the
1736    /// service's answer also produces an error, and that error is about a non-running id
1737    /// rather than about which of two things the caller got wrong.
1738    ///
1739    /// **Falsification** — delete the `lifecycle != Running` branch from `suspend` and the
1740    /// SUSPENDED case emits a `SuspendMicrovm` call, turning the count assertion red.
1741    /// Verified; see the packet's guard proofs.
1742    #[tokio::test]
1743    async fn a_suspend_from_a_non_running_state_reaches_no_control_plane_call() {
1744        // From SUSPENDED — the caller who believes they resumed.
1745        let (mut sandbox, recorder, _) = suspended().await;
1746        let before = recorder.call_count("SuspendMicrovm");
1747
1748        let error = sandbox
1749            .suspend()
1750            .await
1751            .expect_err("a suspend is only issued from RUNNING");
1752        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1753        assert!(error.to_string().contains("STATE-5"), "{error}");
1754        assert!(error.to_string().contains("SUSPENDED"), "{error}");
1755        assert_eq!(
1756            recorder.call_count("SuspendMicrovm"),
1757            before,
1758            "the refusal must be local: no second suspend went to the wire"
1759        );
1760
1761        // And from PENDING, before anything launched at all.
1762        let (mut fresh, fresh_recorder, _) = planted();
1763        let error = fresh.suspend().await.expect_err("nothing to suspend");
1764        assert_eq!(error.kind(), ErrorKind::Precondition);
1765        assert_eq!(fresh_recorder.calls().len(), 0);
1766    }
1767
1768    /// **STATE-7 and STATE-8.** A resume from SUSPENDED returns to RUNNING, re-delivers
1769    /// **nothing**, and drops the cached proxy token.
1770    ///
1771    /// Three assertions, and each one is a different failure mode. The bootstrap count says
1772    /// no run-hook payload was re-delivered — which a daemon's one-shot bootstrap would
1773    /// answer 409 to, reading like a broken VM. The `RunMicrovm` count says no second launch
1774    /// happened. And `is_cached` says the token was invalidated, which is the only
1775    /// observable difference on a path where the endpoint URL does not change: a client that
1776    /// kept the token would produce identical requests until the pre-suspend token stopped
1777    /// validating, and that rejection reads exactly like a dead daemon.
1778    #[tokio::test]
1779    async fn a_resume_reuses_the_token_redelivers_nothing_and_drops_the_proxy_token() {
1780        let (mut sandbox, recorder, _) = suspended().await;
1781
1782        // The session mints once before the suspend, so there is a cached token to drop.
1783        let auth = Arc::clone(
1784            sandbox
1785                .session()
1786                .expect("launched")
1787                .proxy_auth()
1788                .expect("the launch wired a minter"),
1789        );
1790        auth.headers().await.expect("mints");
1791        assert!(
1792            auth.is_cached(),
1793            "there must be a token for the drop to matter"
1794        );
1795        assert_eq!(auth.mint_count(), 1);
1796
1797        recorder
1798            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1799            .answer(
1800                "GetMicrovm",
1801                Answer::ok(fake::microvm_response("RUNNING", None)),
1802            );
1803        let session = sandbox.resume().await.expect("resumes");
1804        assert_eq!(
1805            session.endpoint(),
1806            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1807        );
1808
1809        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1810        assert!(
1811            sandbox.token_installed(),
1812            "STATE-7: the installed token is reused"
1813        );
1814        assert_eq!(
1815            sandbox.bootstrap_count(),
1816            1,
1817            "STATE-7: a resume re-delivers no run-hook payload"
1818        );
1819        assert_eq!(
1820            recorder.call_count("RunMicrovm"),
1821            1,
1822            "a resume is not a second launch"
1823        );
1824        assert!(
1825            !auth.is_cached(),
1826            "STATE-8: the cached proxy token survived the resume"
1827        );
1828    }
1829
1830    /// **STATE-8's guard proof, on the mint count.** The request after a resume mints a
1831    /// fresh token rather than reusing the pre-suspend one.
1832    ///
1833    /// Separate from the test above because `is_cached` is a fact about the cache and this is
1834    /// a fact about the wire: a client that dropped the cache but kept emitting the old
1835    /// header would pass the first assertion and fail every request.
1836    ///
1837    /// **Falsification** — delete the `proxy.invalidate()` line from `Session::rebind` and
1838    /// the mint count stays at 1 here. Verified; see the packet's guard proofs.
1839    #[tokio::test]
1840    async fn the_request_after_a_resume_mints_a_fresh_proxy_token() {
1841        let (mut sandbox, recorder, _) = suspended().await;
1842        let auth = Arc::clone(
1843            sandbox
1844                .session()
1845                .expect("launched")
1846                .proxy_auth()
1847                .expect("wired"),
1848        );
1849        auth.headers().await.expect("mints");
1850        assert_eq!(auth.mint_count(), 1);
1851
1852        recorder
1853            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1854            .answer(
1855                "GetMicrovm",
1856                Answer::ok(fake::microvm_response("RUNNING", None)),
1857            );
1858        sandbox.resume().await.expect("resumes");
1859
1860        // No clock movement at all, so a re-mint here is caused by the invalidation rather
1861        // than by the refresh window rolling over.
1862        auth.headers().await.expect("re-mints");
1863        assert_eq!(
1864            auth.mint_count(),
1865            2,
1866            "STATE-8: the resume did not force a fresh mint"
1867        );
1868    }
1869
1870    /// **STATE-12, the guard proof.** A resume past the launch-time suspended window is
1871    /// refused locally, with **zero** resume calls, and the message names the window.
1872    ///
1873    /// Four assertions, and the packet asks for all four because each rules out a different
1874    /// wrong implementation. `ErrorKind::WindowClosed` rather than `Timeout` separates this
1875    /// from the client that waits. Zero `ResumeMicrovm` calls is what "before any wire call"
1876    /// means. The elapsed reading rules out an implementation that polled to the deadline
1877    /// first — the fake's clock advances on every `sleep`, so a poll loop here would show up
1878    /// as time passed. And the message naming both numbers plus `idlePolicy` is what sends a
1879    /// reader to the finding rather than looking for the flag that reopens the window.
1880    ///
1881    /// **Falsification** — delete the `require_open_suspended_window` call from `resume` and
1882    /// the fake answers TERMINATED, so this test fails with an `ErrorKind::LaunchDied` after
1883    /// a `ResumeMicrovm` call, naming neither the window nor the seconds elapsed. Verified;
1884    /// see the packet's guard proofs.
1885    #[tokio::test]
1886    async fn a_resume_past_the_suspended_window_is_refused_before_the_wire() {
1887        // A short window, so the arithmetic is legible: sixty seconds asked for at launch.
1888        let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1889        assert_eq!(sandbox.suspended_window(), Some(Duration::from_secs(60)));
1890
1891        // The window closes. This is the whole of what the clock injection buys.
1892        clock.advance(Duration::from_secs(61));
1893
1894        // What a service says about a VM the idlePolicy already terminated — which is what a
1895        // client without the local check would spend its poll timeout discovering.
1896        recorder
1897            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1898            .answer(
1899                "GetMicrovm",
1900                Answer::ok(fake::microvm_response(
1901                    "TERMINATED",
1902                    Some("suspended window elapsed"),
1903                )),
1904            );
1905        let elapsed_before = clock.now();
1906        let resumes_before = recorder.call_count("ResumeMicrovm");
1907
1908        let error = sandbox
1909            .resume()
1910            .await
1911            .expect_err("the window the launch set has closed");
1912
1913        assert_eq!(error.kind(), ErrorKind::WindowClosed);
1914        assert_eq!(error.code(), "ERR_WINDOW_CLOSED");
1915        let message = error.to_string();
1916        assert!(message.contains("61s"), "the elapsed time: {message}");
1917        assert!(
1918            message.contains("60s suspendedDurationSeconds"),
1919            "the window has to be named: {message}"
1920        );
1921        assert!(message.contains("idlePolicy"), "the finding: {message}");
1922        assert!(
1923            message.contains("GetMicrovm does not return it"),
1924            "why the client is the only party that can say this: {message}"
1925        );
1926        assert_eq!(
1927            recorder.call_count("ResumeMicrovm"),
1928            resumes_before,
1929            "the refusal must come before ResumeMicrovm"
1930        );
1931        assert_eq!(
1932            clock.now(),
1933            elapsed_before,
1934            "the refusal must be immediate rather than polled to the deadline"
1935        );
1936    }
1937
1938    /// A resume **inside** the window goes through, so the guard is a comparison rather than
1939    /// a blanket refusal of every resume.
1940    ///
1941    /// The boundary is inclusive: elapsed exactly equal to the window is still open, matching
1942    /// the Python's `elapsed <= window`.
1943    #[tokio::test]
1944    async fn a_resume_inside_the_window_is_allowed_and_the_boundary_is_inclusive() {
1945        let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1946
1947        // Exactly at the window.
1948        clock.advance(Duration::from_secs(60));
1949        recorder
1950            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1951            .answer(
1952                "GetMicrovm",
1953                Answer::ok(fake::microvm_response("RUNNING", None)),
1954            );
1955        sandbox
1956            .resume()
1957            .await
1958            .expect("60s == the 60s window is open");
1959        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1960        assert_eq!(recorder.call_count("ResumeMicrovm"), 1);
1961    }
1962
1963    /// A successful resume clears the stamp, so the next cycle's window is measured from the
1964    /// next suspend rather than from the first one.
1965    ///
1966    /// The failure this rules out is subtle and would only show on a second cycle:
1967    /// accumulating every suspension's elapsed time into one total refuses a resume whose own
1968    /// window is wide open. The clock is advanced past the window *between* the two cycles,
1969    /// which is what makes the accumulating implementation fail here.
1970    #[tokio::test]
1971    async fn a_successful_resume_clears_the_stamp_so_a_second_cycle_measures_its_own_window() {
1972        let (mut sandbox, recorder, clock) = planted();
1973        // Every `GetMicrovm` answer for both cycles, in the order the waits consume them:
1974        // the launch's RUNNING, then SUSPENDED / RUNNING twice. Queued up front for the
1975        // reason `suspended_with_window` documents — a stale answer left at the head makes a
1976        // wait poll twice, and the fake's `sleep` moves the very clock this test is measuring.
1977        recorder
1978            .answer(
1979                "RunMicrovm",
1980                Answer::ok(fake::microvm_response("PENDING", None)),
1981            )
1982            .answer(
1983                "CreateMicrovmAuthToken",
1984                Answer::ok(fake::auth_token_response("proxy-token")),
1985            )
1986            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1987            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1988            .answer(
1989                "GetMicrovm",
1990                Answer::ok(fake::microvm_response("RUNNING", None)),
1991            )
1992            .answer(
1993                "GetMicrovm",
1994                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1995            )
1996            .answer(
1997                "GetMicrovm",
1998                Answer::ok(fake::microvm_response("RUNNING", None)),
1999            )
2000            .answer(
2001                "GetMicrovm",
2002                Answer::ok(fake::microvm_response("SUSPENDED", None)),
2003            )
2004            .answer(
2005                "GetMicrovm",
2006                Answer::ok(fake::microvm_response("RUNNING", None)),
2007            );
2008        sandbox
2009            .run(
2010                RunRequest::new()
2011                    .with_image("arn:image")
2012                    .with_suspended_sec(60),
2013            )
2014            .await
2015            .expect("launches");
2016
2017        for cycle in 0..2 {
2018            sandbox
2019                .suspend()
2020                .await
2021                .unwrap_or_else(|error| panic!("cycle {cycle} suspend: {error}"));
2022            assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended, "cycle {cycle}");
2023
2024            // Forty-five seconds each time — inside the sixty-second window on its own, and
2025            // ninety in total. An implementation that never cleared the stamp measures the
2026            // total and refuses the second resume, which is the whole point of the loop.
2027            clock.advance(Duration::from_secs(45));
2028
2029            sandbox.resume().await.unwrap_or_else(|error| {
2030                panic!("cycle {cycle} resume, whose own window is wide open: {error}")
2031            });
2032            assert_eq!(sandbox.lifecycle(), Lifecycle::Running, "cycle {cycle}");
2033            assert_eq!(
2034                sandbox.bootstrap_count(),
2035                1,
2036                "cycle {cycle}: no re-bootstrap"
2037            );
2038        }
2039
2040        assert_eq!(
2041            clock.now(),
2042            Duration::from_secs(90),
2043            "ninety seconds of accumulated suspension against a sixty-second window: this is \
2044             the total an implementation that never cleared the stamp would have compared"
2045        );
2046        assert_eq!(
2047            recorder.call_count("ResumeMicrovm"),
2048            2,
2049            "both cycles resumed"
2050        );
2051    }
2052
2053    /// **STATE-11, the resume-after-terminate case the packet names.** A terminated VM never
2054    /// returns to RUNNING, and the recorder sees **zero** resume wire calls.
2055    ///
2056    /// The zero count is the assertion the packet asks for, and it is the right one: a client
2057    /// that called and read the answer would also fail, with whatever the service says about
2058    /// a terminated id — which is a different statement from "this VM is gone and even a
2059    /// successful call would hand you a different machine".
2060    #[tokio::test]
2061    async fn a_resume_after_terminate_records_zero_resume_calls() {
2062        let (mut sandbox, recorder, _) = launched().await;
2063        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2064        let report = sandbox.terminate(TeardownOpts::default()).await;
2065        assert!(report.terminate_accepted);
2066        assert!(sandbox.was_terminated());
2067
2068        let error = sandbox
2069            .resume()
2070            .await
2071            .expect_err("a terminated VM never returns to RUNNING");
2072        assert_eq!(error.kind(), ErrorKind::InvalidArg);
2073        assert!(error.to_string().contains("STATE-11"), "{error}");
2074        assert_eq!(
2075            recorder.call_count("ResumeMicrovm"),
2076            0,
2077            "no resume may reach the wire once the VM was terminated"
2078        );
2079        assert_ne!(
2080            sandbox.lifecycle(),
2081            Lifecycle::Running,
2082            "the lifecycle must not return to RUNNING"
2083        );
2084    }
2085
2086    /// **STATE-9 and STATE-10.** A terminate accepted is TERMINATING with the VM recorded
2087    /// terminated; the platform reporting termination complete is what marks TERMINATED.
2088    ///
2089    /// The two are separate states rather than one because the default teardown does not
2090    /// wait — so TERMINATING is a state a report really ends in, and calling it TERMINATED
2091    /// would claim an observation nobody made.
2092    #[tokio::test]
2093    async fn a_terminate_records_terminating_and_the_completion_report_marks_terminated() {
2094        let (mut sandbox, recorder, _) = launched().await;
2095        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2096
2097        let report = sandbox.terminate(TeardownOpts::default()).await;
2098        assert!(report.terminate_accepted, "STATE-9: the call was accepted");
2099        assert!(sandbox.was_terminated(), "STATE-9: recorded terminated");
2100        assert_eq!(
2101            report.lifecycle,
2102            Some(Lifecycle::Terminating),
2103            "the default teardown does not wait, so TERMINATED is not claimed"
2104        );
2105        assert!(!report.leaked(), "{report:?}");
2106
2107        // And with the wait asked for, STATE-10 is observed.
2108        let (mut sandbox, recorder, _) = launched().await;
2109        recorder
2110            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2111            .answer(
2112                "GetMicrovm",
2113                Answer::ok(fake::microvm_response("TERMINATED", None)),
2114            );
2115        let report = sandbox
2116            .terminate(TeardownOpts::default().waiting_for_terminated())
2117            .await;
2118        assert_eq!(report.lifecycle, Some(Lifecycle::Terminated));
2119        assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
2120    }
2121
2122    /// The teardown order is VM, then image, then the log group **last** — asserted on the
2123    /// recorder's ledger rather than on the report, because the order is a property of what
2124    /// was emitted.
2125    ///
2126    /// The log group is last because the service can recreate a group deleted before its
2127    /// image. This crate cannot delete it at all, so the assertion is that it is *named
2128    /// after* the image work rather than that a delete call went out — see
2129    /// `TeardownReport::undeleted`.
2130    #[tokio::test]
2131    async fn teardown_deletes_the_vm_then_the_image_and_names_the_log_group_last() {
2132        let (mut sandbox, recorder, _) = planted();
2133        recorder
2134            .answer(
2135                "CreateMicrovmImage",
2136                Answer::created(fake::create_image_response("agentd-conformance")),
2137            )
2138            .answer(
2139                "GetMicrovmImage",
2140                Answer::ok(fake::get_image_response("agentd-conformance", "CREATED")),
2141            );
2142        sandbox
2143            .build_image(CreateImageRequest::new(
2144                "agentd-conformance",
2145                b"binary".to_vec(),
2146                "s3://bucket/img.zip",
2147                "arn:aws:iam::123456789012:role/build",
2148            ))
2149            .await
2150            .expect("builds");
2151        answer_launch(&recorder);
2152        sandbox
2153            .run(RunRequest::new())
2154            .await
2155            .expect("launches from the built image");
2156
2157        recorder
2158            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2159            .answer(
2160                "ListMicrovmImageVersions",
2161                Answer::ok(fake::list_versions_response("1")),
2162            )
2163            .answer(
2164                "DeleteMicrovmImage",
2165                Answer::ok(fake::delete_image_response()),
2166            );
2167
2168        let report = sandbox
2169            .terminate(
2170                TeardownOpts::default()
2171                    .deleting_image()
2172                    .deleting_log_group(),
2173            )
2174            .await;
2175        assert!(report.terminate_accepted);
2176        assert_eq!(report.image_deleted, Some(true));
2177
2178        // The ledger, filtered to the three destructive operations.
2179        let order: Vec<&str> = recorder
2180            .operations()
2181            .into_iter()
2182            .filter(|operation| matches!(*operation, "TerminateMicrovm" | "DeleteMicrovmImage"))
2183            .collect();
2184        assert_eq!(
2185            order,
2186            ["TerminateMicrovm", "DeleteMicrovmImage"],
2187            "the VM goes before the image: a terminating VM holds a reference to it"
2188        );
2189
2190        // The log group is named, and named last.
2191        assert_eq!(
2192            report.undeleted,
2193            ["/aws/lambda-microvms/agentd-conformance"],
2194            "{report:?}"
2195        );
2196        assert!(report.leaked());
2197        assert!(
2198            report
2199                .failures
2200                .last()
2201                .expect("a failure")
2202                .contains("log group"),
2203            "the log group's failure has to be the last one recorded: {report:?}"
2204        );
2205        assert!(
2206            report
2207                .failures
2208                .last()
2209                .expect("a failure")
2210                .contains("terraform destroy"),
2211            "the reason it leaks has to be named: {report:?}"
2212        );
2213    }
2214
2215    /// An image delete that fails every attempt lands its identifier in the report rather
2216    /// than raising, so the CLI can emit what was left behind (CLI-6).
2217    ///
2218    /// The identifier rather than a flag is the whole point: a leak nobody can name is a leak
2219    /// nobody can clean up.
2220    #[tokio::test]
2221    async fn a_failing_image_delete_names_what_it_left_behind() {
2222        let (mut sandbox, recorder, _) = planted();
2223        recorder
2224            .answer(
2225                "CreateMicrovmImage",
2226                Answer::created(fake::create_image_response("img")),
2227            )
2228            .answer(
2229                "GetMicrovmImage",
2230                Answer::ok(fake::get_image_response("img", "CREATED")),
2231            );
2232        sandbox
2233            .build_image(CreateImageRequest::new(
2234                "img",
2235                b"binary".to_vec(),
2236                "s3://bucket/img.zip",
2237                "arn:aws:iam::123456789012:role/build",
2238            ))
2239            .await
2240            .expect("builds");
2241        answer_launch(&recorder);
2242        sandbox.run(RunRequest::new()).await.expect("launches");
2243
2244        recorder
2245            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2246            .answer(
2247                "ListMicrovmImageVersions",
2248                Answer::ok(fake::list_versions_response("1")),
2249            )
2250            .answer(
2251                "DeleteMicrovmImage",
2252                Answer::failure(409, "image is in CREATING"),
2253            );
2254
2255        let mut opts = TeardownOpts::default().deleting_image();
2256        opts.delete_attempts = 3;
2257        opts.delete_backoff = Duration::from_secs(1);
2258        let report = sandbox.terminate(opts).await;
2259
2260        assert_eq!(report.image_deleted, Some(false));
2261        assert_eq!(
2262            report.undeleted,
2263            ["arn:aws:lambda:us-east-1:123456789012:microvm-image:img"],
2264            "{report:?}"
2265        );
2266        assert!(
2267            sandbox.image_exists(),
2268            "the image really is still there, so the flag must say so"
2269        );
2270        assert_eq!(
2271            recorder.call_count("DeleteMicrovmImage"),
2272            3,
2273            "every attempt ran"
2274        );
2275    }
2276
2277    /// A terminate whose own call fails still records the VM as one this client asked to
2278    /// destroy, and names it as undeleted.
2279    ///
2280    /// Recording it is what stops a later resume from being offered against a VM the caller
2281    /// already tried to kill (STATE-11) — the alternative leaves the sandbox looking
2282    /// resumable, which is the worse of the two wrong answers.
2283    #[tokio::test]
2284    async fn a_terminate_whose_call_fails_still_records_the_intent_and_the_leak() {
2285        let (mut sandbox, recorder, _) = launched().await;
2286        recorder.answer(
2287            "TerminateMicrovm",
2288            Answer::failure(500, "InternalServerException"),
2289        );
2290
2291        let report = sandbox.terminate(TeardownOpts::default()).await;
2292        assert!(!report.terminate_accepted);
2293        assert_eq!(report.undeleted, ["mvm-abc123"], "{report:?}");
2294        assert!(sandbox.was_terminated());
2295        assert_eq!(report.lifecycle, Some(Lifecycle::Terminating));
2296        assert!(
2297            !report.failures.is_empty(),
2298            "the swallowed failure is recorded"
2299        );
2300    }
2301
2302    /// A teardown on a sandbox that never launched is a no-op report rather than a panic.
2303    ///
2304    /// The path a caller reaches by tearing down in a `finally` after a launch that failed
2305    /// before any VM existed, which is exactly when a teardown that assumed a VM would turn
2306    /// the real failure into a panic.
2307    #[tokio::test]
2308    async fn a_teardown_before_any_launch_is_an_empty_report() {
2309        let (mut sandbox, recorder, _) = planted();
2310        let report = sandbox.terminate(TeardownOpts::default()).await;
2311        assert!(!report.terminate_accepted);
2312        assert!(!report.leaked());
2313        assert_eq!(report.lifecycle, Some(Lifecycle::Pending));
2314        assert_eq!(recorder.calls().len(), 0);
2315    }
2316
2317    /// A run with no image at all is a precondition error before any call, rather than a
2318    /// launch of an empty identifier.
2319    #[tokio::test]
2320    async fn a_run_with_no_image_is_refused_before_any_call() {
2321        let (mut sandbox, recorder, _) = planted();
2322        let error = sandbox
2323            .run(RunRequest::new())
2324            .await
2325            .expect_err("there is nothing to launch");
2326        assert_eq!(error.kind(), ErrorKind::Precondition);
2327        assert!(error.to_string().contains("build_image first"), "{error}");
2328        assert_eq!(recorder.calls().len(), 0);
2329    }
2330
2331    /// The lifecycle's six states are the symspec's `vm_state` domain, spelled as the service
2332    /// spells them.
2333    ///
2334    /// Asserted rather than assumed because the spelling reaches an error message a reader
2335    /// compares against a `GetMicrovm` response: a lifecycle rendering `Suspended` beside a
2336    /// service saying `SUSPENDED` reads like two different facts.
2337    #[test]
2338    fn the_lifecycle_states_are_the_state_models_domain_in_the_services_spelling() {
2339        let all = [
2340            Lifecycle::Pending,
2341            Lifecycle::Running,
2342            Lifecycle::Suspending,
2343            Lifecycle::Suspended,
2344            Lifecycle::Terminating,
2345            Lifecycle::Terminated,
2346        ];
2347        assert_eq!(
2348            all.map(Lifecycle::as_str),
2349            [
2350                "PENDING",
2351                "RUNNING",
2352                "SUSPENDING",
2353                "SUSPENDED",
2354                "TERMINATING",
2355                "TERMINATED"
2356            ]
2357        );
2358        for state in all {
2359            assert_eq!(state.to_string(), state.as_str());
2360        }
2361        // The four live states are the ones a `Drop` warns about, and TERMINATING is not one
2362        // of them: the platform accepted the terminate, so nobody needs telling.
2363        assert_eq!(
2364            all.iter().filter(|state| state.is_live()).count(),
2365            4,
2366            "a state added to the enum defaults to not-live, which would silence the warning"
2367        );
2368        assert!(!Lifecycle::Terminating.is_live());
2369        assert!(!Lifecycle::Terminated.is_live());
2370    }
2371
2372    /// An agent token is 64 hex characters and never repeats.
2373    ///
2374    /// The distinctness matters because the token is the only thing separating one VM's
2375    /// control API from another's: two VMs sharing a token means either one's caller can
2376    /// drive the other's guest.
2377    #[test]
2378    fn a_minted_agent_token_is_sixty_four_hex_characters_and_never_repeats() {
2379        let minted: std::collections::HashSet<String> =
2380            (0..200).map(|_| mint_agent_token()).collect();
2381        assert_eq!(minted.len(), 200, "a repeated token is a shared guest");
2382        for token in &minted {
2383            assert_eq!(token.len(), 64, "{token}");
2384            assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}");
2385            assert_ne!(
2386                token,
2387                &"0".repeat(64),
2388                "an all-zero draw is a read that did nothing"
2389            );
2390        }
2391    }
2392
2393    /// A sandbox's `Debug` does not print the agent token.
2394    #[tokio::test]
2395    async fn a_sandbox_debug_does_not_print_the_agent_token() {
2396        let (mut sandbox, recorder, _) = planted();
2397        answer_launch(&recorder);
2398        sandbox
2399            .run(
2400                RunRequest::new()
2401                    .with_image("arn:image")
2402                    .with_agent_token("super-secret-agent-token"),
2403            )
2404            .await
2405            .expect("launches");
2406
2407        let rendered = format!("{sandbox:?}");
2408        assert!(rendered.contains("mvm-abc123"), "{rendered}");
2409        assert!(rendered.contains("Running"), "{rendered}");
2410        assert!(
2411            !rendered.contains("super-secret"),
2412            "the agent token reached a Debug string: {rendered}"
2413        );
2414    }
2415
2416    /// A torn-down sandbox does not warn on drop, and an abandoned one is the case the
2417    /// warning exists for.
2418    ///
2419    /// Asserted on the flag rather than on stderr — capturing another thread's stderr from a
2420    /// test is not something this crate can do without a dependency — so what is pinned is
2421    /// the condition the warning branches on. Both halves, so the branch cannot be
2422    /// vacuously true.
2423    #[tokio::test]
2424    async fn a_torn_down_sandbox_is_distinguishable_from_an_abandoned_one() {
2425        let (mut sandbox, recorder, _) = launched().await;
2426        assert!(
2427            sandbox.lifecycle().is_live() && !sandbox.torn_down,
2428            "an abandoned live VM is what the drop warning is for"
2429        );
2430
2431        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2432        sandbox.terminate(TeardownOpts::default()).await;
2433        assert!(
2434            sandbox.torn_down,
2435            "terminate must mark the sandbox torn down"
2436        );
2437        assert!(
2438            !sandbox.lifecycle().is_live(),
2439            "and the lifecycle must no longer read as live"
2440        );
2441    }
2442}