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