Skip to main content

microvms_core/control/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The control-plane client: SigV4-signed rest-json, and every trap closed before the
3//! request leaves the process.
4//!
5//! # The shape of the thing
6//!
7//! [`ControlPlane`] is the whole surface. It holds a [`transport::Transport`] (signed and
8//! real, or a contract recorder in a test), a [`Region`], and a [`Clock`] — and every
9//! operation is a method on it. There is no builder, no config struct, and no partially
10//! constructed state: [`ControlPlane::new`] resolves credentials and either yields a
11//! usable client or says why not.
12//!
13//! # What is closed here, and how strongly
14//!
15//! * **TRAP-1** — [`token`]: image tokens are always fresh. Run tokens are fresh unless
16//!   explicitly persisted for recovery of one launch. Never reuse them for a new VM.
17//! * **TRAP-2** — [`image`]: a build stuck in `CREATING` past the stall grace with every
18//!   build `PENDING` is rejected naming the replay signature. S2.
19//! * **TRAP-3** — [`CreateImageRequest::repair_guest_identity`] is a `bool`, and the
20//!   request injects `["ALL"]` itself. There is no capability list to put
21//!   `CAP_SYS_ADMIN` in. S1.
22//! * **TRAP-4** — [`connector::ConnectorIntent`]: connectors are derived ARNs from a
23//!   closed intent enum. S1.
24//! * **TRAP-5** — [`RunHookPayload`]: 4096 bytes inclusive, checked before any call. S2,
25//!   and the type means an unchecked string cannot reach the field.
26//! * **TRAP-8** — [`ControlPlane::wait_for_running`]: a terminal state before RUNNING is
27//!   rejected with the state *and* `stateReason` attached. S2.
28//! * **TRAP-11, revised again** — `mint_shell_auth_token` exists now, and the guard
29//!   changed ground with it: the closure is no longer the method's absence but the exec
30//!   path's separation from it, which is why the test still counts the calls a full
31//!   lifecycle made and asserts zero shell operations among them. The connector half is
32//!   unchanged: `SHELL_INGRESS` is a [`connector::ConnectorIntent`] variant, and the
33//!   lifecycle test asserts a launch carries exactly the connectors its caller asked
34//!   for. See [`connector`].
35//!
36//! # TRAP-11 is the one that needs saying out loud
37//!
38//! `CreateMicrovmShellAuthToken` is implemented — issue #69 builds `microvm shell` on
39//! it, and [`ControlPlane::mint_shell_auth_token`] is its one door. The original reason
40//! for staying away entirely — that the shell gates a console-only debugging flow, not
41//! a programmatic exec path — did not survive measurement: `docs/PLATFORM.md`
42//! (2026-08-15) found a real PTY over a WebSocket, programmatically drivable. What
43//! holds is narrower: **one interactive session is not programmatic exec** — no exec
44//! ids, no idempotency, no separated stdout/stderr, no exit codes — so the exec path
45//! never requests the shell connector and never mints a shell token, and the shell is
46//! its own surface ([`crate::session::shell`]) rather than a method the exec path can
47//! wander into.
48//!
49//! # Nothing in this module reads the service model at runtime
50//!
51//! Every constraint is a constant in [`crate::constants`], checked by the build gate
52//! against the pinned model (TRAP-12). That matters because botocore's
53//! `VALIDATED_METADATA_ATTRS` is `{required, min, document, union}` — `max`, `pattern`,
54//! and `enum` violations go to the wire — so every guard in this module is load-bearing
55//! rather than belt-and-braces, and "the SDK validates the model already" was never true.
56//!
57//! # And "botocore validates `min`" was never true *of this client*
58//!
59//! The sentence above is about `max`, `pattern`, and `enum`, and for a while it left `min`
60//! implicitly exempt: `min` **is** in `VALIDATED_METADATA_ATTRS`, so a `min` violation really is
61//! refused before the wire — by botocore. This client does not use botocore. It signs with
62//! `aws-sigv4` and sends with `reqwest`, and `validate.py` is nowhere in the dependency graph.
63//! That reasoning came from the deleted Python client, where it held, and it did not survive the
64//! port; issue #24 measured the consequence, `maxIdleDurationSeconds: 59` on the wire.
65//!
66//! So the rule is simpler than it was: **every constraint the model states on a member this
67//! client sends is enforced by this module or by nothing.** The guards below are the whole of it:
68//!
69//! * [`require_valid_image_name`] — `ImageName` (1..=64, `[a-zA-Z0-9-_]+`).
70//! * [`require_duration_in_range`] — `maximumDurationInSeconds` (1..=28800).
71//! * [`require_valid_version`] — `Version` (1..=2048, `[^\s]+`).
72//! * [`require_non_blank`] — `NonBlankString`, the model's most-reused shape.
73//! * [`require_valid_identifier`] — `MicrovmIdentifier`/`MicrovmImageIdentifier` (1..=256).
74//! * [`require_valid_role_arn`] — `RoleArn` (20..=2048, plus the twelve-digit account).
75//! * [`require_valid_port`] — `PortNumber`/`HooksPortInteger` (`min: 1`; 0 is not a port).
76//! * [`require_idle_duration`] — `maxIdleDurationSeconds` (`min: 60`).
77//! * [`require_valid_tags`] — `TagKey`/`TagValue`, two ceilings and two minima.
78//!
79//! Three constraints are closed by a **type** rather than by a function, which is the stronger
80//! form: [`RunHookPayload`] cannot hold over 4096 bytes, [`ops::VersionStatus`] and
81//! [`ops::HookState`] cannot spell a value the enum does not have, and
82//! [`connector::ConnectorIntent`] cannot name a connector the platform does not publish. A
83//! constraint the type system enforces cannot be forgotten at a call site.
84
85pub mod artifact;
86pub mod connector;
87pub mod image;
88pub mod microvm;
89pub mod ops;
90pub mod token;
91pub mod transport;
92
93use std::sync::Arc;
94use std::time::Duration;
95
96pub use artifact::{
97    BaseImage, Ecosystem, ProjectFiles, artifact_content_hash, build_artifact, default_dockerfile,
98};
99pub use connector::{ConnectorIntent, EgressPosture, PLATFORM_HONOURS_OMITTED_EGRESS};
100pub use image::{Image, WaitOpts};
101pub use microvm::{Microvm, MicrovmFilter, ProxyToken, RunHookPayload};
102
103use crate::error::{Error, ErrorKind};
104use crate::hooks::{BuildHookTimeout, RunHookTimeout};
105use crate::region::Region;
106use crate::sizing::SizeClass;
107
108/// The default agent port, matching the daemon's own default.
109pub const DEFAULT_AGENT_PORT: u16 = 9000;
110
111/// Monotonic time and sleeping, injectable so a wait can be tested without waiting.
112///
113/// # Why a trait rather than `tokio::time::pause`
114///
115/// Because the waits here are driven by *elapsed* comparisons against a deadline and a
116/// stall grace, and a test has to be able to say "now 300 seconds have passed" between two
117/// polls of a fake control plane. A paused tokio clock can do that, but only for code that
118/// sleeps on tokio — and it makes every test in the file share one global clock state.
119/// A [`Clock`] parameter makes the dependency visible in the signature.
120///
121/// Monotonic rather than wall-clock, and this is the same reasoning the Python client's
122/// injectable `time.monotonic` carries: the suspended window is a *duration*, and a wall
123/// clock that steps backward would reopen a closed one.
124pub trait Clock: Send + Sync {
125    /// Monotonic elapsed time since this clock started. Never decreases.
126    fn elapsed(&self) -> Duration;
127
128    /// Sleeps for `duration`.
129    fn sleep(
130        &self,
131        duration: Duration,
132    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>>;
133}
134
135/// The real clock: `Instant` and `tokio::time::sleep`.
136#[derive(Debug)]
137pub struct SystemClock {
138    started: std::time::Instant,
139}
140
141impl SystemClock {
142    pub fn new() -> Self {
143        Self {
144            started: std::time::Instant::now(),
145        }
146    }
147}
148
149impl Default for SystemClock {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl Clock for SystemClock {
156    fn elapsed(&self) -> Duration {
157        self.started.elapsed()
158    }
159
160    fn sleep(
161        &self,
162        duration: Duration,
163    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
164        Box::pin(tokio::time::sleep(duration))
165    }
166}
167
168/// The control-plane client.
169///
170/// The transport and clock sit behind `Arc`, so a caller holding one across tasks does not
171/// need a second credential chain.
172pub struct ControlPlane {
173    transport: Arc<dyn transport::Transport>,
174    region: Region,
175    clock: Arc<dyn Clock>,
176    /// The agent port, which the hooks block and the proxy token both need.
177    port: u16,
178}
179
180impl std::fmt::Debug for ControlPlane {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("ControlPlane")
183            .field("region", &self.region)
184            .field("port", &self.port)
185            .finish_non_exhaustive()
186    }
187}
188
189impl ControlPlane {
190    /// Resolves credentials for `region` and returns a usable client.
191    ///
192    /// The region is a [`Region`], so an unsupported one is either a compile error or a
193    /// visible [`Region::unlisted`] at the call site — TRAP-6 is closed by the type before
194    /// this function is reached, which is why there is no region check here.
195    pub async fn new(region: Region) -> Result<Self, Error> {
196        let transport = transport::SignedTransport::new(region.clone()).await?;
197        Ok(Self {
198            transport: Arc::new(transport),
199            region,
200            clock: Arc::new(SystemClock::new()),
201            port: DEFAULT_AGENT_PORT,
202        })
203    }
204
205    /// A client over a caller-supplied transport and clock.
206    ///
207    /// The seam every test uses, and the reason it is public rather than `cfg(test)`: the
208    /// state-machine lane (T-W3-6) drives a `ControlPlane` and needs the same seam to test
209    /// its own lifecycle without AWS. A caller who passes a real transport here gets
210    /// exactly what [`ControlPlane::new`] builds.
211    pub fn with_transport(
212        transport: Arc<dyn transport::Transport>,
213        region: Region,
214        clock: Arc<dyn Clock>,
215    ) -> Self {
216        Self {
217            transport,
218            region,
219            clock,
220            port: DEFAULT_AGENT_PORT,
221        }
222    }
223
224    /// Sets the agent port the hooks block and the proxy token name.
225    ///
226    /// # Why this is fallible where every other builder method is not
227    ///
228    /// Because `port` is the one value on this type that the model bounds, and 0 is a value a
229    /// caller reaches for on purpose. `PortNumber` and `HooksPortInteger` are both `min: 1`;
230    /// `u16` closes the ceiling and nothing closed the floor, so before this returned a `Result`
231    /// the port set here landed in two places on the wire — `hooks.port` on every
232    /// `CreateMicrovmImage` built through the plane, and `allowedPorts: [{"port": 0}]` on every
233    /// token it minted (issue #24).
234    ///
235    /// Both of those are guarded at the call itself as well ([`require_valid_port`] in
236    /// [`ControlPlane::create_image`] and in [`ControlPlane::mint_auth_token_for`]), so the
237    /// constraint would be closed without this. It is fallible anyway because the two refusals
238    /// arrive a long way from the mistake: the create one after an artifact upload, and the mint
239    /// one at the moment a session needs a credential. Refusing here means `--port 0` fails
240    /// before a control plane is even usable, which is the difference between "that port is not
241    /// legal" and "your launch failed".
242    ///
243    /// Zero rather than the ceiling is what this actually catches, and it is not a typo case: 0
244    /// is what "let the kernel choose" means to a listener, so it is the value a caller passes
245    /// when they have a socket they have not bound yet.
246    pub fn with_port(mut self, port: u16) -> Result<Self, Error> {
247        require_valid_port("port", port)?;
248        self.port = port;
249        Ok(self)
250    }
251
252    /// The region every ARN in a request is derived for.
253    pub fn region(&self) -> &Region {
254        &self.region
255    }
256
257    /// The agent port.
258    pub fn port(&self) -> u16 {
259        self.port
260    }
261
262    pub(crate) fn transport(&self) -> &dyn transport::Transport {
263        self.transport.as_ref()
264    }
265
266    pub(crate) fn clock(&self) -> &dyn Clock {
267        self.clock.as_ref()
268    }
269}
270
271/// Everything `CreateMicrovmImage` needs, with the traps closed in the type.
272///
273/// # What is deliberately not here
274///
275/// A `client_token` field. See [`token`] for the fifteen-hour wedge that absence prevents;
276/// [`CreateImageRequest::token_scope`] is a **label** folded in beside a fresh nonce and
277/// cannot become the token.
278///
279/// A `capabilities` list. See `repair_guest_identity`.
280///
281/// An `architecture` field. The model's enum has exactly one value, so the only thing a
282/// field could express is a rejected request — and the rejection arrives *after* the
283/// artifact upload, reported as a constraint on a field the caller thought was a choice.
284/// `ARM_64` is injected. A caller who built an x86 binary finds out from `doctor`'s ELF
285/// check, not from a build cycle.
286#[derive(Clone, Debug)]
287pub struct CreateImageRequest {
288    /// The image name. Validated against `ImageName` (1..=64, `[a-zA-Z0-9-_]+`) before the
289    /// artifact is uploaded, because AWS's rejection arrives *after* the upload.
290    pub name: String,
291    /// The base image, pairing the `baseImageArn` with the Dockerfile `FROM`.
292    pub base_image: BaseImage,
293    /// `baseImageVersion`, or `None` to take whatever the service currently defaults to.
294    ///
295    /// # Why pinning is worth a field
296    ///
297    /// Without it a build floats. The managed base's version list is not static — `al2023-1`
298    /// carried one version in June and two by July (`"0"` and `"1"`, measured 2026-08-16) — so
299    /// two builds of identical inputs weeks apart can sit on different bases, and neither
300    /// recorded which. That is a reproducibility hole with no local symptom: the build
301    /// succeeds either way, and the difference appears in the guest.
302    ///
303    /// `None` is the default and stays the default, because pinning without knowing the legal
304    /// values is how a caller pins to a version that has been withdrawn.
305    /// [`ControlPlane::managed_base_versions`] is what answers that, and the version strings
306    /// are **bare integers** for a managed base where a custom image's are `"1.0"` — the two
307    /// are not comparable, so a value from anywhere else does not belong here.
308    ///
309    /// Checked against the `Version` shape before the call, because the create happens after
310    /// the artifact upload.
311    pub base_image_version: Option<String>,
312    /// The Dockerfile, or `None` for the derived default.
313    ///
314    /// A caller-supplied one is checked against `base_image`'s `docker_ref`: the two
315    /// disagreeing builds against a base none of the measured platform behaviour applies
316    /// to.
317    pub dockerfile: Option<String>,
318    /// The daemon binary's bytes, zipped into the artifact.
319    pub binary: Vec<u8>,
320    /// The project's dependency files — the lockfile plus its manifest — zipped into the
321    /// artifact beside the Dockerfile, or `None` for an image with no environment layer.
322    ///
323    /// This is what lets the derived Dockerfile's install step (`uv sync --locked`,
324    /// `npm ci`, `cargo fetch` per [`Ecosystem`]) see the files it installs from (#74).
325    /// The entry names are fixed by the ecosystem; there is no way to put an arbitrary
326    /// file into the shared snapshot through this field, which is the TRAP-5 property
327    /// restated as a type.
328    pub project_files: Option<ProjectFiles>,
329    /// Where the artifact is uploaded to. This client does not upload — S3 is not in the
330    /// crate's dependency set — so the caller puts the bytes there and passes the URI.
331    pub code_artifact_uri: String,
332    /// The build role, which must grant logs on `/aws/lambda-microvms/*`.
333    pub build_role_arn: String,
334    /// The size class. Selects a documented baseline; it does not size the VM (TRAP-10).
335    pub size: SizeClass,
336    /// Whether to repair guest identity (TRAP-3).
337    ///
338    /// # Why a bool and not a capability list
339    ///
340    /// Measured 2026-08-06: without the capability, a guest running as root still gets
341    /// `EPERM` from `sethostname` and from a bind mount over
342    /// `/proc/sys/kernel/random/boot_id`, because the MicroVM drops `CAP_SYS_ADMIN` by
343    /// default. Writing `/etc/machine-id` needs no capability and succeeds either way,
344    /// which is what makes the gap easy to miss — identity repair looks like it works
345    /// until you check the two steps that need the kernel's permission rather than the
346    /// filesystem's.
347    ///
348    /// `Capability`'s enum is exactly `["ALL"]`, so there is no way to ask for
349    /// `CAP_SYS_ADMIN` alone. A list parameter would let a caller write
350    /// `["CAP_SYS_ADMIN"]` — the request AWS rejects *after* the artifact upload — so the
351    /// intent is a boolean and the request injects `["ALL"]` itself.
352    pub repair_guest_identity: bool,
353    /// Whether the daemon should inherit the image's `WORKDIR`.
354    ///
355    /// Rejected when nothing declares one: most public ARM64 bases leave it empty, so the
356    /// inheritance silently resolves to `/` and the symptom appears in the guest a build
357    /// cycle later.
358    pub inherit_workdir: bool,
359    /// The run-family hook timeout: `run`, `resume`, `suspend`, `terminate`. Caps at 60s,
360    /// and the type is what keeps a build-sized value out of it.
361    pub run_hook_timeout: RunHookTimeout,
362    /// The build-family hook timeout: `ready`, `validate`. Caps at 3600s.
363    pub build_hook_timeout: BuildHookTimeout,
364    /// Tags, or none.
365    pub tags: std::collections::BTreeMap<String, String>,
366    /// `logging.cloudWatch.logGroup`, or `None` for the service default — a
367    /// service-created group under `/aws/lambda-microvms/<image-name>` with random stream
368    /// names.
369    ///
370    /// Checked against `CloudWatchLoggingLogGroupString` (1..=512, `[a-zA-Z0-9_\-/.#]+`)
371    /// before the artifact upload. The build role must be able to write to whatever this
372    /// names: the conformance account's role grants logs only on
373    /// `/aws/lambda-microvms/*`, so a group outside that prefix builds with no logs at
374    /// all — the same failure mode as the wrong-prefix policy (docs/PLATFORM.md).
375    pub log_group: Option<String>,
376    /// `logging.cloudWatch.logStream` — a stream-name **prefix**, never the exact name.
377    ///
378    /// # The client always appends a per-build discriminator
379    ///
380    /// The wire member is an EXACT stream name (prefixes unsupported — measured 2026-08,
381    /// docs/PLATFORM.md 'An image build is three VMs and three log streams'), and one
382    /// build emits three streams. A fixed configured name collapses all three of every
383    /// build into one stream, so [`ControlPlane::create_image`] sends
384    /// `<this>/<16 hex>` with fresh CSPRNG per create attempt — the same mechanism as
385    /// the `clientToken` nonce (TRAP-1) — and the resolved name comes back on
386    /// [`image::Image::log_stream`] so the caller can find their logs.
387    ///
388    /// Capped at [`crate::constants::MAX_USER_LOG_STREAM_LEN`] (495) so the resolved name
389    /// fits the shape's 512, and refused when it carries `:` or `*` (the pattern is
390    /// `[^:*]*`). Requires [`Self::log_group`]: a stream inside a group the service
391    /// chose randomly is a stream nobody can predict the location of.
392    pub log_stream: Option<String>,
393    /// A **label** folded into the create token for CloudTrail readability, defaulting to
394    /// the image name. Not the token, and cannot become it (TRAP-1).
395    pub token_scope: Option<String>,
396}
397
398impl CreateImageRequest {
399    /// A request with the measured defaults: the al2023 base, the default size class, the
400    /// derived Dockerfile, identity repair off, and 30-second hook timeouts.
401    ///
402    /// The hook timeouts are `expect`ed rather than propagated because 30 is legal for both
403    /// families by construction — it is below the run family's 60s ceiling, which is the
404    /// lower of the two — and a `Result` here would put a `?` on every call site for a
405    /// value this function chose itself.
406    pub fn new(
407        name: impl Into<String>,
408        binary: Vec<u8>,
409        code_artifact_uri: impl Into<String>,
410        build_role_arn: impl Into<String>,
411    ) -> Self {
412        Self {
413            name: name.into(),
414            base_image: BaseImage::al2023(),
415            base_image_version: None,
416            dockerfile: None,
417            binary,
418            project_files: None,
419            code_artifact_uri: code_artifact_uri.into(),
420            build_role_arn: build_role_arn.into(),
421            size: SizeClass::DEFAULT,
422            repair_guest_identity: false,
423            inherit_workdir: false,
424            run_hook_timeout: RunHookTimeout::try_new(30).expect("30s is under the 60s ceiling"),
425            build_hook_timeout: BuildHookTimeout::try_new(30)
426                .expect("30s is under the 3600s ceiling"),
427            tags: std::collections::BTreeMap::new(),
428            log_group: None,
429            log_stream: None,
430            token_scope: None,
431        }
432    }
433}
434
435/// Everything `RunMicrovm` needs.
436///
437/// `run_hook_payload` is a [`RunHookPayload`], which cannot hold an over-ceiling value —
438/// so TRAP-5 is closed before this struct exists rather than checked when it is used.
439#[derive(Clone, Debug)]
440pub struct RunMicrovmRequest {
441    /// The image ARN or ID to launch.
442    pub image_identifier: String,
443    /// `imageVersion`, or `None` for the image's own latest active version.
444    ///
445    /// # The launch half of blue/green
446    ///
447    /// `None` is what this client always sent, and it means "whatever
448    /// `latestActiveImageVersion` is now" — which is right for the ordinary case and wrong for
449    /// the two that matter. A canary wants to launch against exactly the version it is
450    /// testing, not against whatever became latest while it was starting. And a rollback wants
451    /// to re-pin to the known-good version, which "latest" cannot express at all once a bad
452    /// version is the latest one.
453    ///
454    /// With [`ControlPlane::set_image_version_status`] the three moves compose: build v2,
455    /// canary-launch pinned to v2, and on failure set v2 INACTIVE and re-pin to v1. A version
456    /// set INACTIVE refuses to launch when pinned here, which is what makes the retire real
457    /// rather than advisory.
458    ///
459    /// Checked against the `Version` shape before the call — see
460    /// [`require_valid_version`] on why a rollback is the worst moment for a rejection about
461    /// the request.
462    pub image_version: Option<String>,
463    /// The execution role. Optional in the model; every real launch needs one.
464    pub execution_role_arn: Option<String>,
465    /// The connectors to request, as intents (TRAP-4). Ingress is required for a session
466    /// to work at all; omitting egress omits the connector from the request (measured
467    /// 2026-09-12: the platform gave the VM outbound network anyway, `docs/PLATFORM.md`).
468    pub connectors: Vec<ConnectorIntent>,
469    /// Customer-managed Lambda VPC egress connector ARNs, created through `lambda-core`.
470    /// Cannot be combined with [`ConnectorIntent::Egress`]. Internet isolation requires
471    /// a VPC without an internet gateway or NAT gateway; an ARN alone proves no isolation.
472    pub egress_network_connectors: Vec<String>,
473    /// The already-validated payload carrying the agent token.
474    pub run_hook_payload: RunHookPayload,
475    /// `maximumDurationInSeconds`, checked against 1..=28800 when the request is sent.
476    pub max_duration_sec: u32,
477    /// `idlePolicy.maxIdleDurationSeconds`, checked against the model's `min: 60` before the call.
478    ///
479    /// This comment used to say the guard was deliberately absent, because `min` is one of the four
480    /// keys botocore's `VALIDATED_METADATA_ATTRS` enforces locally. True of botocore, and this
481    /// client never touches it — see [`require_idle_duration`]. `59` reached the wire.
482    pub max_idle_sec: u32,
483    /// `idlePolicy.suspendedDurationSeconds`.
484    ///
485    /// The model's `min` is **0**, so every `u32` satisfies it and there is no guard — an absence
486    /// with a reason rather than an oversight, and the drift gate pins the 0 so a future floor is
487    /// noticed. Its ceiling is unstated.
488    ///
489    /// The requested value drives the local resume window. The service also reports its
490    /// active policy in [`Microvm::idle_policy`].
491    pub suspended_sec: u32,
492    /// `idlePolicy.autoResumeEnabled`.
493    pub auto_resume: bool,
494    /// A label for the run token, defaulting to the image identifier (TRAP-1).
495    pub token_scope: Option<String>,
496    /// Optional durable launch idempotency key. Reuse only for retries of one launch,
497    /// with identical parameters and run-hook payload. None preserves fresh defaults.
498    /// Never derive this from an image name or reuse it for a different VM.
499    pub client_token: Option<String>,
500    /// Per-VM `logging`: a CloudWatch group (and optional exact stream) or disabled.
501    /// `None` leaves the service's default destination in place (#201).
502    pub logging: Option<ops::Logging>,
503}
504
505impl RunMicrovmRequest {
506    /// A launch with the measured defaults: ingress only, ten-minute idle and suspended
507    /// windows, a one-hour maximum duration, no auto-resume.
508    pub fn new(image_identifier: impl Into<String>, run_hook_payload: RunHookPayload) -> Self {
509        Self {
510            image_identifier: image_identifier.into(),
511            image_version: None,
512            execution_role_arn: None,
513            connectors: vec![ConnectorIntent::AllIngress],
514            egress_network_connectors: Vec::new(),
515            run_hook_payload,
516            max_duration_sec: 3_600,
517            max_idle_sec: 600,
518            suspended_sec: 600,
519            auto_resume: false,
520            token_scope: None,
521            client_token: None,
522            logging: None,
523        }
524    }
525
526    /// Requests the managed internet egress connector. Omission does not block internet access.
527    #[must_use]
528    pub fn with_egress(mut self) -> Self {
529        if !self.connectors.contains(&ConnectorIntent::Egress) {
530            self.connectors.push(ConnectorIntent::Egress);
531        }
532        self
533    }
534
535    /// Routes egress through a customer-managed Lambda network connector.
536    #[must_use]
537    pub fn with_egress_network_connector(mut self, arn: impl Into<String>) -> Self {
538        self.egress_network_connectors.push(arn.into());
539        self
540    }
541
542    /// Requests a shell-capable launch: the ingress set becomes the measured pair,
543    /// `[HTTP_INGRESS, SHELL_INGRESS]`.
544    ///
545    /// **Replaces** `ALL_INGRESS` rather than adding beside it, because the platform
546    /// forbids the combination and says so only at token-mint time — the VM launches,
547    /// reaches RUNNING, and bills before the failure appears (`docs/PLATFORM.md`,
548    /// 2026-08-15). `[HTTP_INGRESS, SHELL_INGRESS]` is the set measured to both launch
549    /// and mint a shell token, and `HTTP_INGRESS` stays in the pair so the daemon
550    /// endpoint keeps working. Egress is untouched: shell access and outbound network
551    /// are separate questions.
552    #[must_use]
553    pub fn with_shell(mut self) -> Self {
554        self.connectors
555            .retain(|intent| *intent != ConnectorIntent::AllIngress);
556        for wanted in [ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress] {
557            if !self.connectors.contains(&wanted) {
558                self.connectors.push(wanted);
559            }
560        }
561        self
562    }
563
564    /// Pins the launch to one `imageVersion`. See the field for why that matters.
565    #[must_use]
566    pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
567        self.image_version = Some(version.into());
568        self
569    }
570}
571
572/// Rejects a `maximumDurationInSeconds` outside the service range.
573///
574/// 28800 is eight hours and the hard ceiling on any single VM's life. A longer session
575/// needs a second VM, not a larger number — which is what the message says, because
576/// "outside the accepted range" alone invites someone to look for the flag that raises it.
577pub fn require_duration_in_range(seconds: u32) -> Result<u32, Error> {
578    if (1..=crate::constants::MAX_DURATION_SEC).contains(&seconds) {
579        return Ok(seconds);
580    }
581    Err(Error::invalid_arg(format!(
582        "maximumDurationInSeconds={seconds} is outside the accepted range 1..{} (service model \
583         {}) — {} seconds is eight hours, the hard ceiling on any one VM's life. A longer \
584         session needs a second VM, not a larger number.",
585        crate::constants::MAX_DURATION_SEC,
586        crate::constants::MODEL_API_VERSION,
587        crate::constants::MAX_DURATION_SEC,
588    )))
589}
590
591/// Rejects a `Version`/`NonBlankString` value the service would reject.
592///
593/// # Why this one is checked locally when so many are not
594///
595/// The model's `Version` shape is `min: 1, max: 2048, pattern: [^\s]+`, and issue #24 lists
596/// `NonBlankString` as its most-reused unguarded shape. Two of the three constraints are ones
597/// botocore would not catch even if this client used it (`max` and `pattern` are outside
598/// `VALIDATED_METADATA_ATTRS`), and this client uses `aws-sigv4` plus `reqwest` and never
599/// touches botocore's validator at all.
600///
601/// What makes it worth a guard rather than a comment is *where* the two callers sit.
602/// `CreateMicrovmImage.baseImageVersion` is sent **after** the artifact upload, so the
603/// service's rejection costs the caller the upload — the same argument
604/// [`require_valid_image_name`] makes. And `RunMicrovm.imageVersion` is the pinned-launch
605/// half of a rollback: a blank there is a `ValidationException` at the moment someone is
606/// trying to re-pin away from a bad version, which is the worst possible time for the failure
607/// to be about the request rather than about the version.
608///
609/// The pattern is `[^\s]+` — **no whitespace anywhere**, not merely "not blank". A version
610/// copied out of a terminal with a trailing newline satisfies "non-empty" and fails the
611/// pattern, so the message names the character rather than saying the value is invalid.
612pub fn require_valid_version(member: &str, version: &str) -> Result<(), Error> {
613    let model = crate::constants::MODEL_API_VERSION;
614    if version.is_empty() {
615        return Err(Error::invalid_arg(format!(
616            "{member} is empty, but the Version shape requires at least 1 character (service \
617             model {model}). Omit the member entirely to let the service choose — an absent \
618             version and a blank one are different requests, and only the first is legal."
619        )));
620    }
621    if version.len() > crate::constants::MAX_VERSION_LEN {
622        return Err(Error::invalid_arg(format!(
623            "{member} is {} characters, over the Version ceiling of {} (service model {model}).",
624            version.len(),
625            crate::constants::MAX_VERSION_LEN,
626        )));
627    }
628    if let Some(found) = version.chars().find(char::is_ascii_whitespace) {
629        return Err(Error::invalid_arg(format!(
630            "{member} {version:?} contains whitespace ({found:?}), which the Version pattern \
631             {:?} forbids anywhere in the value (service model {model}). A version pasted from \
632             a terminal carries a trailing newline and looks fine; this is that.",
633            crate::constants::VERSION_PATTERN,
634        )));
635    }
636    Ok(())
637}
638
639/// Rejects a `NonBlankString` value the service would reject.
640///
641/// # The model's most-reused shape, and the three members this client sends
642///
643/// `NonBlankString` is `min: 1, max: 2048, pattern: [^\s]+` and 45 members name it. Most are
644/// responses; the three this client puts on the wire are `CodeArtifact.uri`,
645/// `CreateMicrovmImage.baseImageArn`, and `ListMicrovmImages.nameFilter`, and issue #24 named
646/// all three as reachable with no guard.
647///
648/// Each of the three fails in its own expensive way. `codeArtifact.uri` and `baseImageArn` ride
649/// on the create call, which happens **after** the artifact upload — the ordering
650/// [`ControlPlane::create_image`] is arranged around, so a rejection about either costs the
651/// caller the upload. `nameFilter` is worse in a quieter way: it goes in the **query string**,
652/// so a blank one is a `nameFilter=` that either 400s or silently filters differently from what
653/// was meant, and [`ControlPlane::find_image_by_name`] is the resolver a launch depends on.
654///
655/// # `[^\s]+` forbids whitespace *anywhere*
656///
657/// Not merely "not blank". An S3 URI pasted from a console with a trailing newline satisfies
658/// "non-empty" and fails the pattern, which is why the message names the character it found
659/// rather than saying the value is invalid. The same reading [`require_valid_version`] documents
660/// for `Version`, which is the identical constraint triple on a different shape.
661///
662/// A separate function from [`require_valid_version`] even though the two shapes agree today,
663/// for the reason [`crate::constants::MAX_NON_BLANK_LEN`] gives: they are two shapes, AWS can
664/// move one, and one guard serving both would silently stop being about whichever moved.
665pub fn require_non_blank(member: &str, value: &str) -> Result<(), Error> {
666    let model = crate::constants::MODEL_API_VERSION;
667    if value.is_empty() {
668        return Err(Error::invalid_arg(format!(
669            "{member} is empty, but the NonBlankString shape requires at least 1 character \
670             (service model {model}). The shape's own documentation reads 'a string which is not \
671             empty or blank (only whitespace)'."
672        )));
673    }
674    if value.len() > crate::constants::MAX_NON_BLANK_LEN {
675        return Err(Error::invalid_arg(format!(
676            "{member} is {} characters, over the NonBlankString ceiling of {} (service model \
677             {model}).",
678            value.len(),
679            crate::constants::MAX_NON_BLANK_LEN,
680        )));
681    }
682    if let Some(found) = value.chars().find(char::is_ascii_whitespace) {
683        return Err(Error::invalid_arg(format!(
684            "{member} {value:?} contains whitespace ({found:?}), which the NonBlankString \
685             pattern {:?} forbids anywhere in the value (service model {model}). A URI pasted \
686             from a console carries a trailing newline and looks fine; this is that.",
687            crate::constants::NON_BLANK_PATTERN,
688        )));
689    }
690    Ok(())
691}
692
693/// Rejects a `MicrovmIdentifier`/`MicrovmImageIdentifier` the service would reject.
694///
695/// # Twelve members across every implemented operation
696///
697/// Both shapes are `min: 1, max: 256`, and between them they bound the identifier on
698/// `GetMicrovm`, `SuspendMicrovm`, `ResumeMicrovm`, `TerminateMicrovm`,
699/// `CreateMicrovmAuthToken`, `GetMicrovmImage`, `DeleteMicrovmImage`,
700/// `DeleteMicrovmImageVersion`, `GetMicrovmImageVersion`, `UpdateMicrovmImageVersion`,
701/// `GetMicrovmImageBuild`, the three listings, and `RunMicrovm.imageIdentifier`. Issue #24
702/// counted six implemented operations for each shape; every one of them is guarded here.
703///
704/// # An empty identifier is the case that pays for this
705///
706/// Ten of those members are **URI parameters**, and an empty one does not produce a validation
707/// error about a blank field: it collapses the path. `GET /2025-09-09/microvms/` addresses the
708/// *listing*, not a VM, so an empty id sent to `get_microvm` asks a different question and can
709/// get a 200 back for it — and `DELETE` on a collapsed path is worse. That failure mode is
710/// invisible in the service's answer, which is the strongest argument for a local refusal
711/// anywhere in this module.
712///
713/// # The model contradicts itself, and this is the side that can be guarded
714///
715/// `MicrovmImageArn` permits 2048 and is what the service *answers* with, so the model allows a
716/// legal response value that is an illegal request value — see
717/// [`crate::constants::MAX_IDENTIFIER_LEN`] for the whole account. The message says so, because
718/// a caller who hit it by echoing an ARN back is looking at a service inconsistency rather than
719/// at their own mistake, and telling them "shorten it" would be useless advice about a value
720/// they did not choose.
721pub fn require_valid_identifier(member: &str, identifier: &str) -> Result<(), Error> {
722    let model = crate::constants::MODEL_API_VERSION;
723    if identifier.is_empty() {
724        return Err(Error::invalid_arg(format!(
725            "{member} is empty, but the identifier shapes require at least 1 character (service \
726             model {model}). An empty identifier is not refused as a blank field — most of these \
727             are URI parameters, so it collapses the path onto the collection: \
728             `/microvms/` is the listing rather than one VM, and a request that addresses the \
729             wrong resource can succeed."
730        )));
731    }
732    if identifier.len() > crate::constants::MAX_IDENTIFIER_LEN {
733        return Err(Error::invalid_arg(format!(
734            "{member} is {} characters, over the {} the MicrovmIdentifier and \
735             MicrovmImageIdentifier shapes allow (service model {model}). Note the model \
736             disagrees with itself here: MicrovmImageArn permits {}, and that is the shape the \
737             service answers with on GetMicrovm's imageArn — so if this value came back from a \
738             response rather than being chosen here, it is a service-side inconsistency worth \
739             reporting rather than a length to shorten.",
740            identifier.len(),
741            crate::constants::MAX_IDENTIFIER_LEN,
742            crate::constants::MAX_IMAGE_ARN_LEN,
743        )));
744    }
745    Ok(())
746}
747
748/// Rejects a `RoleArn` the service would reject, before the artifact upload.
749///
750/// # Why the build role in particular
751///
752/// `CreateMicrovmImage.buildRoleArn` is sent on the create call, and the create call happens
753/// **after** the caller has uploaded the artifact. So the service's rejection of a malformed
754/// role ARN arrives having already cost the upload, which is the exact ordering
755/// [`ControlPlane::create_image`] exists to protect against and the reason issue #24 singled
756/// this member out. `RunMicrovm.executionRoleArn` is cheaper to get wrong but wrong in a
757/// confusing place: it is optional in the model, so a malformed one is a `ValidationException`
758/// on a member a caller may not know is being filled from their infra config.
759///
760/// # Three constraints, three messages
761///
762/// `min: 20`, `max: 2048`, and the pattern. They get separate messages because the pattern's
763/// wording ("this does not look like an IAM role ARN") is unhelpful for a value that is merely
764/// 3000 characters, and the length wording is unhelpful for a role *name* — which is the most
765/// common mistake and is short.
766///
767/// See [`crate::constants::is_valid_role_arn`] for what the structural check decides. It cannot
768/// tell you the role exists or grants the right log permissions; those are the failures that
769/// actually happen most and none of them is knowable without IAM.
770pub fn require_valid_role_arn(member: &str, arn: &str) -> Result<(), Error> {
771    let model = crate::constants::MODEL_API_VERSION;
772    if arn.len() < crate::constants::MIN_ROLE_ARN_LEN {
773        return Err(Error::invalid_arg(format!(
774            "{member} {arn:?} is {} characters, under the RoleArn minimum of {} (service model \
775             {model}). A value this short is almost always a role *name* where the ARN was \
776             wanted — `arn:aws:iam::<12-digit-account>:role/{arn}` is the shape.",
777            arn.len(),
778            crate::constants::MIN_ROLE_ARN_LEN,
779        )));
780    }
781    if arn.len() > crate::constants::MAX_ROLE_ARN_LEN {
782        return Err(Error::invalid_arg(format!(
783            "{member} is {} characters, over the RoleArn ceiling of {} (service model {model}).",
784            arn.len(),
785            crate::constants::MAX_ROLE_ARN_LEN,
786        )));
787    }
788    if !crate::constants::is_valid_role_arn(arn) {
789        return Err(Error::invalid_arg(format!(
790            "{member} {arn:?} does not match the RoleArn pattern {:?} (service model {model}). \
791             It wants `arn:aws:iam::` then **exactly twelve digits** of account id then `:role/` \
792             and a name — so the three things this catches are a role name passed as an ARN, an \
793             ARN for some other service, and an account id with a digit dropped. Rejected here \
794             rather than by AWS because for buildRoleArn the create call happens *after* the \
795             artifact upload, so the service's answer costs you the upload first. Whether the \
796             role exists and grants logs on {}/* is not checkable locally and is still the \
797             service's answer to give.",
798            crate::constants::ROLE_ARN_PATTERN,
799            image::BUILD_LOG_GROUP_PREFIX,
800        )));
801    }
802    Ok(())
803}
804
805/// Rejects a `logging.cloudWatch.logGroup` the service would reject, before the artifact
806/// upload.
807///
808/// The shape is `CloudWatchLoggingLogGroupString`: 1..=512, pattern `[a-zA-Z0-9_\-/.#]+`.
809/// The character the pattern excludes that a caller writes first is the colon — a group
810/// pasted as an ARN (`arn:aws:logs:...`) fails here with the pattern named, rather than as
811/// a `ValidationException` after the upload.
812pub fn require_valid_log_group(group: &str) -> Result<(), Error> {
813    let model = crate::constants::MODEL_API_VERSION;
814    if group.is_empty() {
815        return Err(Error::invalid_arg(format!(
816            "logging.cloudWatch.logGroup is empty, but the CloudWatchLoggingLogGroupString \
817             shape requires at least 1 character (service model {model}). Omit the setting \
818             entirely to take the service default — a service-created group under \
819             {}/<image-name>.",
820            image::BUILD_LOG_GROUP_PREFIX,
821        )));
822    }
823    if group.len() > crate::constants::MAX_LOG_GROUP_LEN {
824        return Err(Error::invalid_arg(format!(
825            "logging.cloudWatch.logGroup is {} characters, over the \
826             CloudWatchLoggingLogGroupString ceiling of {} (service model {model}).",
827            group.len(),
828            crate::constants::MAX_LOG_GROUP_LEN,
829        )));
830    }
831    if !crate::constants::is_valid_log_group(group) {
832        return Err(Error::invalid_arg(format!(
833            "logging.cloudWatch.logGroup {group:?} does not match the \
834             CloudWatchLoggingLogGroupString pattern {:?} (service model {model}) — letters, \
835             digits, and `_ - / . #` only. A colon usually means an ARN was pasted where the \
836             group *name* was wanted. Rejected here rather than by AWS because the create call \
837             happens after the artifact upload, so the service's answer costs you the upload \
838             first.",
839            crate::constants::LOG_GROUP_PATTERN,
840        )));
841    }
842    Ok(())
843}
844
845/// Rejects a caller-supplied `logging.cloudWatch.logStream` prefix the resolved name could
846/// not legally carry, before the artifact upload.
847///
848/// The shape is `CloudWatchLoggingLogStreamString`: 1..=512, pattern `[^:*]*` — anything
849/// but `:` and `*`. The caller's value is capped at
850/// [`crate::constants::MAX_USER_LOG_STREAM_LEN`] (495) rather than 512, because this
851/// client always appends `/<16 hex>` of per-build discriminator before the value reaches
852/// the wire — see [`CreateImageRequest::log_stream`] for why a verbatim stream name is
853/// never sent — and 495 + 17 is exactly the shape's ceiling.
854pub fn require_valid_log_stream(stream: &str) -> Result<(), Error> {
855    let model = crate::constants::MODEL_API_VERSION;
856    if stream.is_empty() {
857        return Err(Error::invalid_arg(format!(
858            "logging.cloudWatch.logStream is empty, but the CloudWatchLoggingLogStreamString \
859             shape requires at least 1 character (service model {model}). Omit the setting to \
860             let the service name the streams inside the configured group."
861        )));
862    }
863    if stream.len() > crate::constants::MAX_USER_LOG_STREAM_LEN {
864        return Err(Error::invalid_arg(format!(
865            "logging.cloudWatch.logStream is {} characters, over this client's ceiling of {}. \
866             The shape's own ceiling is {} (service model {model}), and the client always \
867             appends a 17-character per-build discriminator (`/` + 16 hex) — one image build \
868             is three VMs writing three streams, and the member is an exact stream name, so a \
869             fixed configured name would collapse every build's streams into one. The \
870             discriminator is what keeps them tellable apart, and it needs its room.",
871            stream.len(),
872            crate::constants::MAX_USER_LOG_STREAM_LEN,
873            crate::constants::MAX_LOG_STREAM_LEN,
874        )));
875    }
876    if let Some(found) = stream.chars().find(|c| matches!(c, ':' | '*')) {
877        return Err(Error::invalid_arg(format!(
878            "logging.cloudWatch.logStream {stream:?} contains {found:?}, which the \
879             CloudWatchLoggingLogStreamString pattern {:?} forbids anywhere in the value \
880             (service model {model}). A `*` usually means a prefix or glob was intended — the \
881             member is an exact stream name, prefixes are unsupported, and this client's own \
882             per-build suffix is how a configured name behaves like one.",
883            crate::constants::LOG_STREAM_PATTERN,
884        )));
885    }
886    Ok(())
887}
888
889/// Rejects a port the `PortNumber` or `HooksPortInteger` shapes would reject. **Minimum 1.**
890///
891/// # Port 0 is representable and means something else
892///
893/// Zero is what "let the kernel pick a port" means to a listener, so it is the value a caller
894/// reaches for when they do not have a port yet — and issue #24 measured that
895/// [`ControlPlane::with_port`]`(0)` was representable and produced `allowedPorts: [{"port": 0}]`
896/// on the wire, plus a `hooks.port` of 0 on every image built through that plane. Neither is a
897/// port the platform can forward to.
898///
899/// # There is no ceiling check here, and that is the type's doing rather than an omission
900///
901/// `PortNumber.max` and `HooksPortInteger.max` are both 65535, which is `u16::MAX` — so a `max`
902/// branch against the constant would be `port > u16::MAX`, a comparison that is false for every
903/// input. Clippy's `absurd_extreme_comparisons` says so, and it is right for the reason
904/// [`ControlPlane::create_image`] gives about the absent `hooks.port` check: a branch no input can
905/// reach reads as protection, appears in a coverage report as protection, and no test can make it
906/// fire.
907///
908/// The ceiling is still *pinned* — [`crate::constants::MAX_PORT`] is in the drift gate, and
909/// `the_port_floor_is_one_and_the_ceiling_is_what_a_u16_holds` asserts the equality with
910/// `u16::MAX` that makes this reasoning valid. If AWS ever lowers the ceiling below 65535 the gate
911/// goes red, that test goes red, and a real branch belongs here.
912pub fn require_valid_port(member: &str, port: u16) -> Result<(), Error> {
913    if port >= crate::constants::MIN_PORT {
914        return Ok(());
915    }
916    Err(Error::invalid_arg(format!(
917        "{member} is {port}, under the PortNumber minimum of {} (service model {}). Port 0 means \
918         'let the kernel choose' to a listener and is not a port the platform can forward to — a \
919         proxy token minted for it authorizes nothing, and an image whose hooks block names it has \
920         no reachable hook endpoint. Name the port the daemon actually listens on \
921         ({DEFAULT_AGENT_PORT} by default).",
922        crate::constants::MIN_PORT,
923        crate::constants::MODEL_API_VERSION,
924    )))
925}
926
927/// Rejects an `idlePolicy.maxIdleDurationSeconds` under the model's `min: 60`.
928///
929/// # The exemption this replaces was inherited from a client that no longer exists
930///
931/// [`RunMicrovmRequest::max_idle_sec`] and `constants.rs` both used to say there was
932/// deliberately no guard here, because `min` is one of the four keys in botocore's
933/// `VALIDATED_METADATA_ATTRS` and botocore therefore refuses it locally with a clear message.
934/// The premise is true and the conclusion does not apply: this client never touches botocore.
935/// `ControlPlane` signs with `aws-sigv4` and sends with `reqwest`, so `validate.py` is not on
936/// the path, and issue #24 measured `max_idle_sec: 59` reaching the wire.
937///
938/// It is the one place in this module where the *reason* for having no guard was wrong rather
939/// than the number, which is worth saying because the same reasoning would exempt every `min` in
940/// the model — `Version`'s, `NonBlankString`'s, the identifiers', `allowedPorts`'s. All of them
941/// are now guarded, and none of them by botocore.
942///
943/// No maximum, because the model states none. The bound that actually ends a VM's life is
944/// `maximumDurationInSeconds` ([`require_duration_in_range`]).
945pub fn require_idle_duration(seconds: u32) -> Result<(), Error> {
946    if seconds >= crate::constants::MIN_IDLE_DURATION_SEC {
947        return Ok(());
948    }
949    Err(Error::invalid_arg(format!(
950        "idlePolicy.maxIdleDurationSeconds={seconds} is under the minimum of {} (service model \
951         {}). This used to be documented as a constraint botocore enforced locally, which was \
952         true of the deleted Python client and never of this one — it signs with aws-sigv4 and \
953         sends with reqwest, so nothing validates a request before the wire except this crate.",
954        crate::constants::MIN_IDLE_DURATION_SEC,
955        crate::constants::MODEL_API_VERSION,
956    )))
957}
958
959/// Rejects a tag map the service would reject, naming the key and the half that failed.
960///
961/// # Sent since tags existed, checked by nothing
962///
963/// [`CreateImageRequest::tags`] goes straight onto `CreateMicrovmImage.tags` and was never
964/// validated (issue #24). Same ordering argument as everything else on that call: the rejection
965/// lands after the artifact upload.
966///
967/// # The key and the value have different rules, so the message says which
968///
969/// `TagKey` is `min: 1, max: 128`; `TagValue` is `min: 0, max: 256`. So an empty **value** is
970/// legal and an empty **key** is not, and the ceilings differ by 2x. They share one pattern.
971/// Every message here names the offending key, because a caller applying a dozen tags from a
972/// config file needs to know which one — a message that only said "a tag is invalid" would send
973/// them to read all twelve.
974///
975/// The key is quoted in the message even when the key itself is the problem, which is the
976/// deliberate choice: a key with a stray newline or a zero-width character in it is
977/// indistinguishable from a good one until it is printed with `{:?}`.
978pub fn require_valid_tags(tags: &std::collections::BTreeMap<String, String>) -> Result<(), Error> {
979    let model = crate::constants::MODEL_API_VERSION;
980    for (key, value) in tags {
981        if key.is_empty() {
982            return Err(Error::invalid_arg(format!(
983                "a tag key is empty, but TagKey requires at least 1 character (service model \
984                 {model}). Note TagValue's minimum is 0, so an empty tag *value* is legal and an \
985                 empty key is not."
986            )));
987        }
988        if key.len() > crate::constants::MAX_TAG_KEY_LEN {
989            return Err(Error::invalid_arg(format!(
990                "the tag key {key:?} is {} characters, over the TagKey ceiling of {} (service \
991                 model {model}). TagValue allows {}, so the two halves are not \
992                 interchangeable.",
993                key.len(),
994                crate::constants::MAX_TAG_KEY_LEN,
995                crate::constants::MAX_TAG_VALUE_LEN,
996            )));
997        }
998        if !crate::constants::is_valid_tag_component(key) {
999            return Err(Error::invalid_arg(format!(
1000                "the tag key {key:?} has a character outside the TagKey pattern {:?} (service \
1001                 model {model}). Letters, digits, separators, and `_ . : / = + - @` — spaces and \
1002                 non-Latin scripts are fine, commas and `#` and `%` are not, and neither is a \
1003                 newline (it is a control character, not a separator).",
1004                crate::constants::TAG_COMPONENT_PATTERN,
1005            )));
1006        }
1007        if value.len() > crate::constants::MAX_TAG_VALUE_LEN {
1008            return Err(Error::invalid_arg(format!(
1009                "the value of tag {key:?} is {} characters, over the TagValue ceiling of {} \
1010                 (service model {model}).",
1011                value.len(),
1012                crate::constants::MAX_TAG_VALUE_LEN,
1013            )));
1014        }
1015        if !crate::constants::is_valid_tag_component(value) {
1016            return Err(Error::invalid_arg(format!(
1017                "the value of tag {key:?} has a character outside the TagValue pattern {:?} \
1018                 (service model {model}). It is the same character set the key takes; the value \
1019                 differs only in allowing an empty string.",
1020                crate::constants::TAG_COMPONENT_PATTERN,
1021            )));
1022        }
1023    }
1024    Ok(())
1025}
1026
1027/// Rejects an image name the service would reject, before the artifact upload.
1028///
1029/// The three cases get their own messages because the pattern message ("no dots, no
1030/// slashes") is actively misleading for a 70-character name that contains neither.
1031pub fn require_valid_image_name(name: &str) -> Result<(), Error> {
1032    let model = crate::constants::MODEL_API_VERSION;
1033    if name.is_empty() {
1034        return Err(Error::invalid_arg(format!(
1035            "the image name is empty, but ImageName requires at least 1 character (service \
1036             model {model})."
1037        )));
1038    }
1039    if name.len() > crate::constants::MAX_IMAGE_NAME_LEN {
1040        return Err(Error::invalid_arg(format!(
1041            "the image name is {} characters, over the ImageName ceiling of {} (service model \
1042             {model}). Rejected here rather than by AWS, because the create call happens \
1043             *after* the artifact upload — so the service's answer costs you the upload first.",
1044            name.len(),
1045            crate::constants::MAX_IMAGE_NAME_LEN,
1046        )));
1047    }
1048    if !crate::constants::is_valid_image_name(name) {
1049        return Err(Error::invalid_arg(format!(
1050            "the image name {name:?} does not match the ImageName pattern {:?} (service model \
1051             {model}). Letters, digits, hyphen, and underscore only — no dots and no slashes, \
1052             which are the two separators a namespaced name reaches for first.",
1053            crate::constants::IMAGE_NAME_PATTERN,
1054        )));
1055    }
1056    Ok(())
1057}
1058
1059/// The `Hooks` block with all six hooks enabled and both timeout families set.
1060///
1061/// All six because `ready` and `validate` are image-*build* hooks: the build calls them to
1062/// decide whether the snapshot it just produced is usable, before any instance exists and
1063/// therefore before any token has been delivered. Gating them on bootstrap state fails the
1064/// *build* rather than the run, which is a confusing place to discover the mistake.
1065///
1066/// The two timeouts are separate types, so the 60x gap cannot be crossed by passing one
1067/// number twice — see [`crate::hooks`].
1068/// `ENABLED` is [`ops::HookState::Enabled`] rather than a `&str` local, which is issue #24's
1069/// point about this function: the literal appeared six times with no constant naming either
1070/// value, so a typo in one of the six was a `ValidationException` on a create call made after
1071/// the artifact upload. It is now a compile error.
1072pub fn hooks_block(port: u16, run: RunHookTimeout, build: BuildHookTimeout) -> ops::Hooks {
1073    const ENABLED: ops::HookState = ops::HookState::Enabled;
1074    ops::Hooks {
1075        port,
1076        microvm_hooks: ops::MicrovmHooks {
1077            run: ENABLED,
1078            run_timeout_in_seconds: run.as_secs(),
1079            resume: ENABLED,
1080            resume_timeout_in_seconds: run.as_secs(),
1081            suspend: ENABLED,
1082            suspend_timeout_in_seconds: run.as_secs(),
1083            terminate: ENABLED,
1084            terminate_timeout_in_seconds: run.as_secs(),
1085        },
1086        microvm_image_hooks: ops::MicrovmImageHooks {
1087            ready: ENABLED,
1088            ready_timeout_in_seconds: build.as_secs(),
1089            validate: ENABLED,
1090            validate_timeout_in_seconds: build.as_secs(),
1091        },
1092    }
1093}
1094
1095/// A deadline elapsed on the client side. The remote resource is untouched.
1096pub(crate) fn timed_out(what: &str, waited: Duration) -> Error {
1097    Error::new(
1098        ErrorKind::Timeout,
1099        format!(
1100            "{what} within {:.0}s. This is a client-side deadline: nothing was cancelled, so \
1101             the resource is in whatever state the service last reported.",
1102            waited.as_secs_f64()
1103        ),
1104    )
1105}
1106
1107#[cfg(test)]
1108pub(crate) mod fake;
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    /// The three image-name rejections each get their own message, because the pattern
1115    /// wording is misleading for a name that is merely long.
1116    #[test]
1117    fn each_image_name_rejection_explains_its_own_case() {
1118        let empty = require_valid_image_name("").expect_err("min is 1");
1119        assert!(
1120            empty.to_string().contains("at least 1 character"),
1121            "{empty}"
1122        );
1123
1124        let long = require_valid_image_name(&"a".repeat(65)).expect_err("max is 64");
1125        let message = long.to_string();
1126        assert!(message.contains("65 characters"), "{message}");
1127        assert!(
1128            message.contains("after* the artifact upload"),
1129            "the reason to check locally is the cost: {message}"
1130        );
1131        assert!(
1132            !message.contains("no dots"),
1133            "the pattern wording is misleading for a merely-long name: {message}"
1134        );
1135
1136        let dotted = require_valid_image_name("my.image").expect_err("dots are not in the pattern");
1137        let message = dotted.to_string();
1138        assert!(message.contains("no dots and no slashes"), "{message}");
1139        assert!(message.contains("[a-zA-Z0-9-_]+"), "{message}");
1140    }
1141
1142    /// The boundaries are inclusive on both sides, and every rejection is an `InvalidArg`
1143    /// so the CLI reports it as refused-locally rather than as a platform failure.
1144    #[test]
1145    fn an_image_name_at_the_ceiling_is_accepted_and_one_past_it_is_not() {
1146        require_valid_image_name(&"a".repeat(64)).expect("64 is the ceiling");
1147        require_valid_image_name("a").expect("1 is the minimum");
1148        for bad in ["", &"a".repeat(65), "my.image", "team/image", "a b"] {
1149            let error = require_valid_image_name(bad).expect_err("rejected");
1150            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1151        }
1152    }
1153
1154    /// The duration range, at both boundaries. 28801 is the value the botocore gap was
1155    /// confirmed with, and the message has to say a second VM rather than a bigger number.
1156    #[test]
1157    fn the_duration_range_is_inclusive_and_names_the_eight_hour_ceiling() {
1158        assert_eq!(require_duration_in_range(1).expect("1 fits"), 1);
1159        assert_eq!(
1160            require_duration_in_range(28_800).expect("the ceiling fits"),
1161            28_800
1162        );
1163
1164        for bad in [0, 28_801, u32::MAX] {
1165            let error = require_duration_in_range(bad).expect_err("outside 1..28800");
1166            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad}");
1167            let message = error.to_string();
1168            assert!(message.contains("eight hours"), "{message}");
1169            assert!(
1170                message.contains("a second VM, not a larger number"),
1171                "{message}"
1172            );
1173        }
1174    }
1175
1176    /// The hooks block enables all six hooks, and the two families carry their own
1177    /// timeouts — which is the whole reason they are two types.
1178    #[test]
1179    fn the_hooks_block_enables_all_six_with_the_two_families_kept_apart() {
1180        let hooks = hooks_block(
1181            9000,
1182            RunHookTimeout::try_new(45).expect("45s is a legal run timeout"),
1183            BuildHookTimeout::try_new(1800).expect("1800s is a legal build timeout"),
1184        );
1185
1186        assert_eq!(hooks.port, 9000);
1187        for state in [
1188            hooks.microvm_hooks.run,
1189            hooks.microvm_hooks.resume,
1190            hooks.microvm_hooks.suspend,
1191            hooks.microvm_hooks.terminate,
1192            hooks.microvm_image_hooks.ready,
1193            hooks.microvm_image_hooks.validate,
1194        ] {
1195            assert_eq!(state, ops::HookState::Enabled, "all six hooks are served");
1196            // The wire spelling too, against a literal: the variant name and the string the
1197            // service reads are two different things, and `HookState::Enabled` rendering as
1198            // anything but `ENABLED` is a rejected create on six fields at once.
1199            assert_eq!(state.as_str(), "ENABLED");
1200        }
1201
1202        assert_eq!(hooks.microvm_hooks.run_timeout_in_seconds, 45);
1203        assert_eq!(hooks.microvm_hooks.terminate_timeout_in_seconds, 45);
1204        assert_eq!(hooks.microvm_image_hooks.ready_timeout_in_seconds, 1800);
1205        assert_eq!(
1206            hooks.microvm_image_hooks.validate_timeout_in_seconds, 1800,
1207            "1800 is legal for the build family and illegal for the run one, which is why \
1208             one shared number cannot serve both"
1209        );
1210    }
1211
1212    /// The default request carries the measured defaults, and identity repair is **off** —
1213    /// a capability granted by default is a capability nobody asked for.
1214    #[test]
1215    fn the_default_create_request_repairs_no_identity_and_takes_the_default_size() {
1216        let request = CreateImageRequest::new("img", vec![1, 2, 3], "s3://b/k", "arn:role");
1217        assert!(!request.repair_guest_identity);
1218        assert!(!request.inherit_workdir);
1219        assert_eq!(request.size, SizeClass::DEFAULT);
1220        assert_eq!(request.base_image, BaseImage::al2023());
1221        assert_eq!(request.token_scope, None);
1222        assert_eq!(request.dockerfile, None);
1223    }
1224
1225    /// The default launch requests ingress and **not** egress: the right default for a
1226    /// daemon that needs none. What the omission buys is measured in `docs/PLATFORM.md`
1227    /// (2026-09-12: the platform gave the connector-less VM outbound network anyway).
1228    #[test]
1229    fn the_default_launch_requests_ingress_only() {
1230        let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1231        let request = RunMicrovmRequest::new("arn:image", payload);
1232        assert_eq!(request.connectors, vec![ConnectorIntent::AllIngress]);
1233
1234        let with_egress = request.with_egress();
1235        assert_eq!(
1236            with_egress.connectors,
1237            vec![ConnectorIntent::AllIngress, ConnectorIntent::Egress]
1238        );
1239    }
1240
1241    /// Asking for egress twice does not send it twice. `NetworkConnectorList` caps at 10
1242    /// and a duplicate is a wasted slot at best.
1243    #[test]
1244    fn asking_for_egress_twice_adds_it_once() {
1245        let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1246        let request = RunMicrovmRequest::new("arn:image", payload)
1247            .with_egress()
1248            .with_egress();
1249        assert_eq!(request.connectors.len(), 2);
1250    }
1251
1252    /// `with_shell` **replaces** `ALL_INGRESS` with the measured pair rather than adding
1253    /// beside it: the platform accepts `ALL_INGRESS` + finer ingress at launch and
1254    /// refuses it only when a shell token is minted, after the VM has run and billed —
1255    /// and `run_microvm` refuses the combination client-side, so a `with_shell` that
1256    /// merely appended would build a request this client's own validation rejects.
1257    ///
1258    /// **Falsification** — watched fail 2026-08-31: dropping the `retain` from
1259    /// `with_shell` fails the first assertion with `ALL_INGRESS` still in the set.
1260    #[test]
1261    fn with_shell_replaces_all_ingress_with_the_measured_pair() {
1262        let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1263        let request = RunMicrovmRequest::new("arn:image", payload).with_shell();
1264        assert_eq!(
1265            request.connectors,
1266            vec![ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress]
1267        );
1268
1269        // Egress is a separate question and survives in either order.
1270        let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1271        let both = RunMicrovmRequest::new("arn:image", payload)
1272            .with_egress()
1273            .with_shell()
1274            .with_shell();
1275        assert_eq!(
1276            both.connectors,
1277            vec![
1278                ConnectorIntent::Egress,
1279                ConnectorIntent::HttpIngress,
1280                ConnectorIntent::ShellIngress
1281            ],
1282            "egress survives, and asking twice adds nothing"
1283        );
1284    }
1285
1286    /// A client-side deadline is an `ErrorKind::Timeout` and says the resource was not
1287    /// touched — because the caller's next question is whether to clean something up.
1288    #[test]
1289    fn a_client_side_deadline_says_the_resource_is_untouched() {
1290        let error = timed_out("the image did not become usable", Duration::from_secs(300));
1291        assert_eq!(error.kind(), ErrorKind::Timeout);
1292        let message = error.to_string();
1293        assert!(message.contains("300s"), "{message}");
1294        assert!(message.contains("nothing was cancelled"), "{message}");
1295    }
1296
1297    /// The default port matches the daemon's, and a caller can change it.
1298    #[test]
1299    fn the_agent_port_defaults_to_nine_thousand_and_is_overridable() {
1300        let plane = ControlPlane::with_transport(
1301            Arc::new(fake::FakeControlPlane::new()),
1302            Region::UsEast1,
1303            Arc::new(fake::TestClock::new()),
1304        );
1305        assert_eq!(plane.port(), DEFAULT_AGENT_PORT);
1306        assert_eq!(plane.port(), 9000);
1307        assert_eq!(
1308            plane.with_port(8080).expect("8080 is a legal port").port(),
1309            8080
1310        );
1311    }
1312
1313    /// **TRAP-1, the compile surface.** Image requests cannot carry
1314    /// a caller-supplied token.
1315    ///
1316    /// Asserted by destructuring rather than by grep: a struct pattern must name every
1317    /// field, so an image `client_token` added later fails to compile this test. That is the
1318    /// closest a test can get to asserting an absence.
1319    #[test]
1320    fn image_requests_forbid_tokens_and_run_requests_require_explicit_opt_in() {
1321        let CreateImageRequest {
1322            name: _,
1323            base_image: _,
1324            base_image_version: _,
1325            dockerfile: _,
1326            binary: _,
1327            // Dependency-file *bytes* under ecosystem-fixed entry names — nothing
1328            // token-shaped, and no field a caller could name an arbitrary file into.
1329            project_files: _,
1330            code_artifact_uri: _,
1331            build_role_arn: _,
1332            size: _,
1333            repair_guest_identity: _,
1334            inherit_workdir: _,
1335            run_hook_timeout: _,
1336            build_hook_timeout: _,
1337            tags: _,
1338            // The logging pair. The stream is a *prefix* the create call suffixes with a
1339            // fresh nonce — the caller cannot name the exact wire stream, which is the
1340            // same closure shape as the token's.
1341            log_group: _,
1342            log_stream: _,
1343            // A *label*, folded in beside a fresh nonce. The only token-adjacent field,
1344            // and it cannot become the token.
1345            token_scope: _,
1346        } = CreateImageRequest::new("img", Vec::new(), "s3://b/k", "arn:role");
1347
1348        let RunMicrovmRequest {
1349            image_identifier: _,
1350            image_version: _,
1351            execution_role_arn: _,
1352            connectors: _,
1353            egress_network_connectors: _,
1354            run_hook_payload: _,
1355            max_duration_sec: _,
1356            max_idle_sec: _,
1357            suspended_sec: _,
1358            auto_resume: _,
1359            token_scope: _,
1360            // A per-VM log destination: a group name and an exact stream, never a token.
1361            logging: _,
1362            client_token,
1363        } = RunMicrovmRequest::new(
1364            "arn:image",
1365            RunHookPayload::for_agent_token("t").expect("fits"),
1366        );
1367        assert!(client_token.is_none());
1368    }
1369
1370    /// **TRAP-1's last mile.** The wire types' `client_token` fields are `pub(crate)`, so a
1371    /// caller outside this crate cannot build a request body with a token of their own.
1372    ///
1373    /// Worth its own test because the hole was real and I opened it: `ops` is a public module
1374    /// and the wire structs are public, so `pub client_token` on them meant an external
1375    /// caller could bypass `CreateImageRequest` entirely, hand-build a
1376    /// `CreateMicrovmImageWire` with a content digest, and serialize it — the wedge with one
1377    /// extra step, past a request type that has no such field.
1378    ///
1379    /// This test can only observe the *inside* of the boundary, since a compile failure
1380    /// outside the crate is not something an inline test can assert. What it pins is that the
1381    /// field is reachable from here and that the value in it came from the minter — so a
1382    /// later widening back to `pub` is at least a visible diff against a test that documents
1383    /// why it is narrow. The external half is enforced by the compiler.
1384    #[test]
1385    fn the_wire_types_token_field_is_crate_private_and_minted() {
1386        let wire = ops::RunMicrovmWire {
1387            image_identifier: "arn:image".to_string(),
1388            image_version: None,
1389            execution_role_arn: None,
1390            ingress_network_connectors: Vec::new(),
1391            egress_network_connectors: None,
1392            idle_policy: ops::IdlePolicy {
1393                max_idle_duration_seconds: 600,
1394                suspended_duration_seconds: 600,
1395                auto_resume_enabled: false,
1396            },
1397            maximum_duration_in_seconds: 3_600,
1398            run_hook_payload: String::new(),
1399            logging: None,
1400            // Reachable from inside the crate, and only ever populated this way.
1401            client_token: token::run_token("arn:image"),
1402        };
1403        assert!(wire.client_token.starts_with("run-"));
1404        assert!(wire.client_token.len() <= crate::constants::MAX_CLIENT_TOKEN_LEN);
1405        assert_ne!(
1406            wire.client_token,
1407            token::run_token("arn:image"),
1408            "two mints of the same scope differ, which is the property the narrowing protects"
1409        );
1410    }
1411
1412    // ── issue #24's guards, at the message level ─────────────────────────────
1413    //
1414    // Each of these asserts the *refusal and its wording*; the zero-call proofs live beside
1415    // the operations they protect, in `image.rs` and `microvm.rs`, because only there can a
1416    // test observe that no control-plane call happened.
1417
1418    /// The three `NonBlankString` rejections each explain their own case, and the pattern one
1419    /// names the character it found.
1420    ///
1421    /// The whitespace message has to name the character, because the whole class of failure this
1422    /// catches is a value that *looks* right: a URI or a filter pasted out of a console carries a
1423    /// trailing newline, satisfies "non-empty", and fails the pattern.
1424    ///
1425    /// **Guard proof.** Delete the `is_empty` branch and the blank case returns `Ok`; delete the
1426    /// whitespace branch and the `"s3://b/k\n"` case does.
1427    #[test]
1428    fn each_non_blank_rejection_explains_its_own_case() {
1429        let blank = require_non_blank("codeArtifact.uri", "").expect_err("min is 1");
1430        assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1431        assert!(
1432            blank.to_string().contains("at least 1 character"),
1433            "{blank}"
1434        );
1435        assert!(
1436            blank.to_string().contains("NonBlankString"),
1437            "the shape has to be named: {blank}"
1438        );
1439
1440        let long = require_non_blank("baseImageArn", &"a".repeat(2049)).expect_err("max is 2048");
1441        let message = long.to_string();
1442        assert!(message.contains("2049 characters"), "{message}");
1443        assert!(message.contains("ceiling of 2048"), "{message}");
1444
1445        let newline = require_non_blank("codeArtifact.uri", "s3://bucket/key.zip\n")
1446            .expect_err("whitespace anywhere is refused");
1447        let message = newline.to_string();
1448        assert!(
1449            message.contains(r"'\n'"),
1450            "the message must name the character it found: {message}"
1451        );
1452        assert!(message.contains("trailing newline"), "{message}");
1453
1454        // Whitespace in the middle, not only at the end: `[^\s]+` forbids it anywhere.
1455        let inner =
1456            require_non_blank("nameFilter", "coding agents").expect_err("an inner space too");
1457        assert!(
1458            inner.to_string().contains("anywhere in the value"),
1459            "{inner}"
1460        );
1461
1462        // And the legal values pass, so the guard is not refusing everything.
1463        require_non_blank("codeArtifact.uri", "s3://bucket/agentd.zip").expect("a real URI");
1464        require_non_blank(
1465            "baseImageArn",
1466            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1467        )
1468        .expect("a real base ARN");
1469        require_non_blank("nameFilter", "coding-agents").expect("a real filter");
1470        require_non_blank("imageVersion", &"9".repeat(2048)).expect("2048 is the ceiling");
1471    }
1472
1473    /// The identifier guard's two cases, and the over-long one names the model's own
1474    /// contradiction rather than telling the caller to shorten a value they may not have chosen.
1475    ///
1476    /// The empty message is the one that carries the reason this guard exists at all: ten of the
1477    /// twelve members are URI parameters, so an empty one collapses the path onto the collection
1478    /// and addresses the wrong resource — which the service can answer 200 to.
1479    ///
1480    /// **Guard proof.** Delete the `is_empty` branch and the blank case returns `Ok`; drop the
1481    /// `MicrovmImageArn` clause and the assertion on `2048` goes red.
1482    #[test]
1483    fn the_identifier_guard_names_the_collapsed_path_and_the_models_contradiction() {
1484        let blank = require_valid_identifier("microvmIdentifier", "").expect_err("min is 1");
1485        assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1486        let message = blank.to_string();
1487        assert!(message.contains("at least 1 character"), "{message}");
1488        assert!(
1489            message.contains("collapses the path"),
1490            "the reason an empty URI parameter is dangerous has to be in the message: {message}"
1491        );
1492
1493        let long =
1494            require_valid_identifier("imageIdentifier", &"a".repeat(257)).expect_err("max is 256");
1495        let message = long.to_string();
1496        assert!(message.contains("257 characters"), "{message}");
1497        assert!(message.contains("256"), "{message}");
1498        assert!(
1499            message.contains("MicrovmImageArn permits 2048"),
1500            "the model's own contradiction has to be named, or the advice is 'shorten a value \
1501             the service gave you': {message}"
1502        );
1503
1504        require_valid_identifier(
1505            "imageIdentifier",
1506            "arn:aws:lambda:us-east-1:1:microvm-image:i",
1507        )
1508        .expect("a real ARN");
1509        require_valid_identifier("microvmIdentifier", "mvm-abc123").expect("a real id");
1510        require_valid_identifier("imageIdentifier", &"a".repeat(256)).expect("256 is the ceiling");
1511    }
1512
1513    /// The role-ARN guard's three cases, and the short one guesses the actual mistake.
1514    ///
1515    /// A value under 20 characters is almost always a role *name*, so the message renders the ARN
1516    /// the caller probably meant rather than restating the bound. That is the difference between
1517    /// a diagnostic and a validation error.
1518    ///
1519    /// **Guard proof.** Delete the `is_valid_role_arn` branch and the eleven-digit case returns
1520    /// `Ok` — which is the case nothing else catches.
1521    #[test]
1522    fn the_role_arn_guard_guesses_the_mistake_a_short_value_is() {
1523        let name = require_valid_role_arn("buildRoleArn", "build-role").expect_err("min is 20");
1524        assert_eq!(name.kind(), ErrorKind::InvalidArg);
1525        let message = name.to_string();
1526        assert!(message.contains("role *name*"), "{message}");
1527        assert!(
1528            message.contains("arn:aws:iam::<12-digit-account>:role/build-role"),
1529            "the message renders the ARN they probably meant: {message}"
1530        );
1531
1532        let long = require_valid_role_arn(
1533            "executionRoleArn",
1534            &format!("arn:aws:iam::123456789012:role/{}", "a".repeat(2048)),
1535        )
1536        .expect_err("max is 2048");
1537        assert!(long.to_string().contains("ceiling of 2048"), "{long}");
1538
1539        // Eleven digits, which is the case no eyeball and no other check catches.
1540        let eleven = require_valid_role_arn("buildRoleArn", "arn:aws:iam::12345678901:role/build")
1541            .expect_err("the account id is eleven digits");
1542        let message = eleven.to_string();
1543        assert!(
1544            message.contains("exactly twelve digits"),
1545            "the digit count is the whole value of this check: {message}"
1546        );
1547        assert!(
1548            message.contains("after* the artifact upload"),
1549            "the reason to check locally is the cost: {message}"
1550        );
1551        assert!(
1552            message.contains("/aws/lambda-microvms/*"),
1553            "and the message says what is still the service's answer to give: {message}"
1554        );
1555
1556        require_valid_role_arn(
1557            "buildRoleArn",
1558            "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
1559        )
1560        .expect("the conformance account's real build role");
1561    }
1562
1563    /// The port guard refuses 0 and says what 0 means, because 0 is not a typo.
1564    ///
1565    /// The ceiling branch cannot fire from a `u16` call site — `MAX_PORT` is `u16::MAX` — so what
1566    /// is asserted here is the floor and the two legal boundaries.
1567    ///
1568    /// **Guard proof.** Change the comparison to `port < 0` (or delete the branch) and the zero
1569    /// case returns `Ok`.
1570    #[test]
1571    fn the_port_guard_refuses_zero_and_says_what_zero_means() {
1572        let zero = require_valid_port("allowedPorts[].port", 0).expect_err("min is 1");
1573        assert_eq!(zero.kind(), ErrorKind::InvalidArg);
1574        let message = zero.to_string();
1575        assert!(
1576            message.contains("let the kernel choose"),
1577            "0 is a value a caller passes on purpose, so the message has to say why it is not a \
1578             port: {message}"
1579        );
1580        assert!(
1581            message.contains("authorizes nothing"),
1582            "the proxy-token consequence: {message}"
1583        );
1584        assert!(message.contains("9000"), "the default is named: {message}");
1585
1586        require_valid_port("port", 1).expect("1 is the minimum");
1587        require_valid_port("port", 9000).expect("the agent port");
1588        require_valid_port("port", 65_535).expect("65535 is the ceiling");
1589    }
1590
1591    /// The idle-duration guard refuses 59 and says the botocore reasoning did not transfer.
1592    ///
1593    /// The message names the wrong premise on purpose. The exemption was not a wrong number, it
1594    /// was a correct fact about a client that no longer exists — and a reader who deletes this
1595    /// guard will do it by reasoning that `min` is validated locally, which is exactly the
1596    /// sentence the message answers.
1597    ///
1598    /// **Guard proof.** Delete the call in `run_microvm` and the zero-call test in `microvm.rs`
1599    /// goes red; invert the comparison here and 600 is refused while 59 passes.
1600    #[test]
1601    fn the_idle_guard_refuses_fifty_nine_and_names_the_premise_that_did_not_transfer() {
1602        let under = require_idle_duration(59).expect_err("min is 60");
1603        assert_eq!(under.kind(), ErrorKind::InvalidArg);
1604        let message = under.to_string();
1605        assert!(message.contains("59"), "{message}");
1606        assert!(message.contains("minimum of 60"), "{message}");
1607        assert!(
1608            message.contains("botocore"),
1609            "the message has to name the premise, because the premise is what was wrong: \
1610             {message}"
1611        );
1612        assert!(
1613            message.contains("aws-sigv4"),
1614            "and say what this client actually does instead: {message}"
1615        );
1616
1617        require_idle_duration(60).expect("60 is the minimum");
1618        require_idle_duration(600).expect("the default");
1619        // No maximum in the model, and the client adds none.
1620        require_idle_duration(u32::MAX).expect("the model states no ceiling");
1621    }
1622
1623    /// Every tag rejection names the offending key, and the key and the value keep their
1624    /// different rules.
1625    ///
1626    /// The two asymmetries are the content of this test: an empty value is legal and an empty key
1627    /// is not, and the ceilings are 128 against 256. A guard that used one rule for both would
1628    /// pass a 200-character key or refuse a 200-character value, and neither is what the service
1629    /// does.
1630    ///
1631    /// **Guard proof.** Use `MAX_TAG_KEY_LEN` for the value branch and the 256-character-value
1632    /// case fails; drop the `key` from any message and the assertion naming it goes red.
1633    #[test]
1634    fn every_tag_rejection_names_the_key_and_the_two_halves_keep_their_own_rules() {
1635        use std::collections::BTreeMap;
1636
1637        let one = |key: &str, value: &str| {
1638            let mut tags = BTreeMap::new();
1639            tags.insert(key.to_string(), value.to_string());
1640            tags
1641        };
1642
1643        // An empty **value** is legal. TagValue's min is 0.
1644        require_valid_tags(&one("owner", "")).expect("an empty tag value is legal");
1645        // An empty **key** is not. TagKey's min is 1.
1646        let blank = require_valid_tags(&one("", "conformance")).expect_err("TagKey min is 1");
1647        assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1648        assert!(
1649            blank.to_string().contains("TagValue's minimum is 0"),
1650            "the asymmetry is the thing to say: {blank}"
1651        );
1652
1653        // The two ceilings differ by 2x, and a 256-character *value* is legal.
1654        require_valid_tags(&one("owner", &"v".repeat(256))).expect("TagValue max is 256");
1655        let long_value = require_valid_tags(&one("owner", &"v".repeat(257)))
1656            .expect_err("257 is one past TagValue");
1657        assert!(long_value.to_string().contains("\"owner\""), "{long_value}");
1658        assert!(
1659            long_value.to_string().contains("257 characters"),
1660            "{long_value}"
1661        );
1662
1663        require_valid_tags(&one(&"k".repeat(128), "v")).expect("TagKey max is 128");
1664        let long_key =
1665            require_valid_tags(&one(&"k".repeat(129), "v")).expect_err("129 is one past TagKey");
1666        let message = long_key.to_string();
1667        assert!(message.contains("129 characters"), "{message}");
1668        assert!(
1669            message.contains("TagValue allows 256"),
1670            "the message says the two halves are not interchangeable: {message}"
1671        );
1672
1673        // The pattern, both halves, with the key named either way.
1674        let bad_key = require_valid_tags(&one("cost,centre", "x")).expect_err("a comma");
1675        let message = bad_key.to_string();
1676        assert!(message.contains("\"cost,centre\""), "{message}");
1677        assert!(
1678            message.contains("commas and `#` and `%` are not"),
1679            "{message}"
1680        );
1681
1682        let bad_value = require_valid_tags(&one("owner", "50%")).expect_err("a percent");
1683        let message = bad_value.to_string();
1684        assert!(
1685            message.contains("the value of tag \"owner\""),
1686            "the message says which half as well as which key: {message}"
1687        );
1688
1689        // A newline in a key: legal-looking, and the reason keys are printed with `{:?}`.
1690        let newline = require_valid_tags(&one("owner\n", "x")).expect_err("a control character");
1691        assert!(
1692            newline.to_string().contains(r#""owner\n""#),
1693            "a key with a stray newline is indistinguishable from a good one until it is quoted: \
1694             {newline}"
1695        );
1696
1697        // And a realistic tag set passes, including a space and a non-Latin key.
1698        let mut real = BTreeMap::new();
1699        real.insert("owner".to_string(), "conformance".to_string());
1700        real.insert("cost centre".to_string(), "team/agents".to_string());
1701        real.insert("所有者".to_string(), "らいす".to_string());
1702        real.insert("empty".to_string(), String::new());
1703        require_valid_tags(&real).expect("a realistic tag set");
1704
1705        // An empty map is not a tag problem, and `create_image` omits the member entirely.
1706        require_valid_tags(&BTreeMap::new()).expect("no tags is not an invalid tag");
1707    }
1708
1709    /// **Issue #24's `HookState` half.** The two values are named by a type, so neither can be
1710    /// misspelled, and the block this client sends is six `Enabled`s.
1711    ///
1712    /// The wire spellings are asserted against literals rather than derived from the variant
1713    /// names, because the variant name and the string the service reads are two different things
1714    /// — the same reason `ops::VersionStatus` does it that way.
1715    ///
1716    /// **Guard proof.** Change `HookState::Enabled`'s rename to `"ENABLE"` and this fails on the
1717    /// serialization assertion; add a third variant and the `HOOK_STATES` comparison fails.
1718    #[test]
1719    fn the_hook_state_enum_is_the_models_two_values_and_nothing_else() {
1720        assert_eq!(ops::HookState::Disabled.as_str(), "DISABLED");
1721        assert_eq!(ops::HookState::Enabled.as_str(), "ENABLED");
1722        assert_eq!(ops::HookState::Enabled.to_string(), "ENABLED");
1723
1724        // Through serde, which is what actually reaches the wire — `as_str` could be right while
1725        // the `rename` is wrong.
1726        assert_eq!(
1727            serde_json::to_value(ops::HookState::Enabled).expect("serialises"),
1728            serde_json::json!("ENABLED")
1729        );
1730        assert_eq!(
1731            serde_json::to_value(ops::HookState::Disabled).expect("serialises"),
1732            serde_json::json!("DISABLED")
1733        );
1734
1735        // The typed spelling and the drift gate's array agree, or one of them is checking
1736        // something the other does not send.
1737        let spelled = [ops::HookState::Disabled, ops::HookState::Enabled];
1738        let mut wire: Vec<&str> = spelled.iter().map(|state| state.as_str()).collect();
1739        wire.sort_unstable();
1740        let mut pinned = crate::constants::HOOK_STATES.to_vec();
1741        pinned.sort_unstable();
1742        assert_eq!(wire, pinned);
1743
1744        // And the misspellings a `String` field allowed are now parse failures on the way in,
1745        // which is the closest an inline test gets to asserting they are compile errors on the
1746        // way out.
1747        for typo in ["\"ENABLE\"", "\"Enabled\"", "\"ACTIVE\"", "\"enabled\""] {
1748            serde_json::from_str::<ops::HookState>(typo)
1749                .expect_err(&format!("{typo} is not a HookState"));
1750        }
1751    }
1752}