Skip to main content

microvms_core/
constants.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Every hardcoded service constraint, and the JSON the build gate checks them with.
3//!
4//! Each number and pattern below is transcribed from the botocore service model for
5//! `lambda-microvms`, API version [`MODEL_API_VERSION`]. The model is a
6//! machine-readable statement of the service's own request validation, and restating
7//! it by hand is how this project published a 16 KB `runHookPayload` ceiling against
8//! a real ceiling of 4096 — wrong by 4x in the dangerous direction, since it told a
9//! caller four times as much secret material fits as actually does.
10//!
11//! # Why they are checked locally at all
12//!
13//! Because the SDK does not check them. Measured 2026-08-07 by reading botocore's
14//! `validate.py`: `VALIDATED_METADATA_ATTRS` is `{'required', 'min', 'document',
15//! 'union'}`, so a `min` violation is caught before the wire while `max`, `pattern`,
16//! and `enum` violations are serialized, sent, and answered with a
17//! `ValidationException` — confirmed empirically for `max` (runHookPayload 4097,
18//! maximumDurationInSeconds 28801, ImageName 65 chars, NetworkConnectorList 11 items,
19//! clientToken 129 chars), for `pattern` (ImageName `"a b!"`), and for `enum`
20//! (architecture X86_64, additionalOsCapabilities CAP_SYS_ADMIN). Every one reached
21//! the wire. So the guards built on these values are load-bearing rather than
22//! belt-and-braces, and the obvious future simplification — "the SDK validates the
23//! model already, delete these" — silently reopens all of them.
24//!
25//! `IdlePolicy.maxIdleDurationSeconds` used to be listed here as the counter-example — the
26//! one constraint with no constant, on the grounds that its `min: 60` is a `min` and
27//! botocore enforces those locally. **That reasoning does not transfer to this client and
28//! the exemption is gone** ([`MIN_IDLE_DURATION_SEC`]). It was true of the deleted Python
29//! client, which called through botocore; this one signs with `aws-sigv4` and sends with
30//! `reqwest`, and `validate.py` is never on the path. Every constraint here is enforced by
31//! this crate or by nothing, `min` included.
32//!
33//! # The drift gate (TRAP-12)
34//!
35//! [`as_json`] emits every value in this module as one object, keyed with the names the
36//! deleted Python client's `sandbox.py` used. That is what makes the gate possible:
37//! `scripts/check-model-drift.py` reads this object and compares each constant against the
38//! pinned model. It used to compare the Python module the same way and then the two
39//! clients against each other; with one client left, the two values no model states —
40//! `MICROVM_REGIONS` and `SIZE_CLASSES` — are compared against pinned literals in that
41//! script instead, since a value compared only against itself passes by construction.
42//!
43//! The names are therefore a contract with a script, not a style choice. Renaming
44//! `MAX_RUN_HOOK_PAYLOAD_BYTES` here without renaming it there does not fail
45//! compilation; it makes a check silently stop comparing. The test at the bottom of
46//! this file pins the key set for that reason.
47
48use serde_json::{Value, json};
49
50use crate::region::MICROVM_REGIONS;
51
52/// The model version every constraint here was read from.
53///
54/// The drift gate hard-fails when this disagrees with the service directory it
55/// resolves, rather than skipping: a constraint checked against a different API
56/// version is a constraint that was not checked.
57pub const MODEL_API_VERSION: &str = "2025-09-09";
58
59/// `RunMicrovmRequestRunHookPayloadString.max`.
60///
61/// **Inclusive**: 4096 bytes passes the length check and 4097 is rejected, bracketed
62/// 2026-08-07 by calling `RunMicrovm` with a deliberately bogus `imageIdentifier` so
63/// nothing could be created or billed. This is the only per-VM secret channel the
64/// platform offers — one bearer token fits, a cloud credential set does not.
65///
66/// # The model itself says 16,384, and it is wrong
67///
68/// Not only this repo's prose. `service-2.json`'s **documentation string** on
69/// `RunMicrovmRequest.runHookPayload` reads, verbatim for API version 2025-09-09:
70///
71/// > Per-MicroVM initialization data delivered as the request body of the /run lifecycle
72/// > hook. Use to pass tenant-specific configuration such as session IDs or secret
73/// > references. Maximum: 16,384 bytes.
74///
75/// while the shape that member names — `RunMicrovmRequestRunHookPayloadString` — declares
76/// `{"max": 4096, "min": 0}`. The shape is what the service validates against, and 4096 was
77/// measured inclusive against the real service. **So a reader who "corrects" this constant
78/// from the model's prose reintroduces the bug**, and they will be able to cite the model
79/// while doing it: that 16 KB figure is where this project's own 16 KB claim came from, and
80/// it survived several review passes because prose has nothing to disagree with. Issue #24
81/// named this trap specifically. The drift gate compares against the *shape*, which is why it
82/// stays green while the prose beside it says something else.
83pub const MAX_RUN_HOOK_PAYLOAD_BYTES: usize = 4096;
84
85/// The figure the model's own `runHookPayload` documentation string claims, which is **not**
86/// the ceiling.
87///
88/// Pinned as a constant so the wrong number has a name and a comparison rather than only a
89/// mention. [`MAX_RUN_HOOK_PAYLOAD_BYTES`] carries the full account; what this exists for is
90/// the drift gate, which reads the model's documentation string and asserts the claim is
91/// still 16,384 — because the day AWS fixes their prose is the day this constant and its
92/// warning should be deleted, and nothing else would say so.
93///
94/// It is deliberately **never** used as a bound. The test below asserts it is 4x the real
95/// ceiling, which is the shape of the hazard: wrong in the permissive direction, telling a
96/// caller four times as much secret material fits as actually does.
97pub const DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES: usize = 16_384;
98
99/// `ImageName.max`.
100///
101/// The minimum is 1 and this comment used to say botocore enforces that one. It does, and this
102/// client does not use botocore — so the min is checked by
103/// [`crate::control::require_valid_image_name`] like everything else, and always was.
104pub const MAX_IMAGE_NAME_LEN: usize = 64;
105
106/// `ImageName.pattern`, as the model spells it.
107///
108/// No dots and no slashes, which rules out the two separators a caller reaching for a
109/// namespaced name writes first. Published as a string for the drift gate; the
110/// matcher is [`is_valid_image_name`], which is hand-rolled rather than a regex
111/// dependency because the character class is four ranges.
112pub const IMAGE_NAME_PATTERN: &str = "[a-zA-Z0-9-_]+";
113
114/// `Version.max`, which is also `NonBlankString.max`.
115///
116/// Checked by [`crate::control::require_valid_version`] on the two members this client sends
117/// as a `Version` — `CreateMicrovmImage.baseImageVersion` and `RunMicrovm.imageVersion`. Issue
118/// #24 named `NonBlankString` as the model's most-reused unguarded shape; these are the two
119/// places it is now guarded, and both are sent at a moment where the service's own rejection
120/// is expensive (after an artifact upload, or in the middle of a rollback).
121pub const MAX_VERSION_LEN: usize = 2048;
122
123/// `Version.pattern` — **no whitespace anywhere**, not merely "not blank".
124///
125/// Published as a string for the drift gate; the check is hand-rolled beside it for the reason
126/// [`IMAGE_NAME_PATTERN`] gives. A version copied out of a terminal carries a trailing newline
127/// and satisfies "non-empty" while failing this, which is why the refusal names the character.
128pub const VERSION_PATTERN: &str = "[^\\s]+";
129
130/// `NonBlankString.max`, which today equals [`MAX_VERSION_LEN`] and is a different shape.
131///
132/// The model's most-reused constrained shape — 45 members name it — and the ones this client
133/// *sends* are `CodeArtifact.uri`, `CreateMicrovmImage.baseImageArn`, and
134/// `ListMicrovmImages.nameFilter`. Checked by [`crate::control::require_non_blank`].
135///
136/// A separate constant from [`MAX_VERSION_LEN`] rather than a reuse of it, and the reason is
137/// the same one the two connector ceilings give: `Version` and `NonBlankString` are two shapes
138/// in the model with two independent futures, and AWS can move one without moving the other.
139/// One constant serving both would be a comparison that silently stops being about one of
140/// them. The drift gate holds each against its own shape.
141pub const MAX_NON_BLANK_LEN: usize = 2048;
142
143/// `NonBlankString.pattern` — the same `[^\s]+` [`VERSION_PATTERN`] carries, held separately
144/// for the reason [`MAX_NON_BLANK_LEN`] gives.
145pub const NON_BLANK_PATTERN: &str = "[^\\s]+";
146
147/// `MicrovmIdentifier.max` and `MicrovmImageIdentifier.max`, which are the same number.
148///
149/// Both shapes are `min: 1, max: 256`, and between them they cover **twelve** URI, body, and
150/// querystring members across every implemented operation — every `GetMicrovm`, `Suspend`,
151/// `Resume`, `Terminate`, `CreateMicrovmAuthToken`, `GetMicrovmImage`, `Delete*`, `List*`, and
152/// `RunMicrovm.imageIdentifier`. Checked by [`crate::control::require_valid_identifier`].
153///
154/// # The model contradicts itself here, and the client resolves it toward 256
155///
156/// `MicrovmImageArn` is `min: 20, max: 2048`, and it is the shape the service **answers** with
157/// on `GetMicrovmResponse.imageArn`, `MicrovmItem.imageArn`, and `RunMicrovmResponse.imageArn`.
158/// So the model permits the service to return a 2048-character image ARN that is then illegal
159/// to pass back as a `MicrovmImageIdentifier`. That is a real contradiction, not a reading
160/// error, and only one side of it can be guarded: the request side.
161///
162/// This client refuses above 256 on the way out, because that is the bound the *request*
163/// shapes state and a request over it is rejected by the service regardless of where the value
164/// came from. Refusing there names the contradiction — see
165/// [`crate::control::require_valid_identifier`], whose message says an over-long identifier
166/// that came from a response is a service-side inconsistency to report rather than a caller
167/// mistake. The alternative, accepting up to 2048 because a response might carry one, would
168/// send a request that cannot succeed and report the service's `ValidationException` as though
169/// the caller had chosen the length.
170///
171/// In practice the gap is theoretical: a real image ARN in the longest-named MicroVM region is
172/// about 90 characters, and the test below pins that arithmetic so "it fits" is measured rather
173/// than assumed.
174pub const MAX_IDENTIFIER_LEN: usize = 256;
175
176/// `MicrovmImageArn.max` — 2048, the number that contradicts [`MAX_IDENTIFIER_LEN`].
177///
178/// Pinned so the contradiction is a comparison rather than a comment: this is a
179/// **response**-side shape, so nothing guards a value against it, and its only job here is to
180/// be the second half of the assertion that the two disagree. If AWS ever narrows this to 256
181/// the contradiction is gone and the paragraph in [`MAX_IDENTIFIER_LEN`] should go with it —
182/// the drift gate is what would notice.
183pub const MAX_IMAGE_ARN_LEN: usize = 2048;
184
185/// `TagKey.max`. Minimum is 1, so a blank key is not a tag.
186pub const MAX_TAG_KEY_LEN: usize = 128;
187
188/// `TagValue.max`. Minimum is **0** — an empty tag value is legal, unlike an empty key.
189pub const MAX_TAG_VALUE_LEN: usize = 256;
190
191/// The `TagKey`/`TagValue` pattern, which both shapes share verbatim.
192///
193/// Unicode property classes — letters, separators, numbers, and
194/// `_ . : / = + - @` — so this is the one pattern in this module that is **not** ASCII ranges,
195/// and the matcher [`is_valid_tag_component`] is written against `char::is_alphabetic` and
196/// friends rather than against byte ranges for that reason.
197///
198/// # The pattern is `*` and therefore matches everything, which is a trap
199///
200/// The whole thing is a `*`-quantified group, so the empty string matches and so does any
201/// prefix — an unanchored `*` pattern rejects nothing at all. What actually bites a caller is
202/// the character *set*: a tag key with a comma, a hash, or a percent in it is outside it. The
203/// matcher therefore checks that **every** character is in the set rather than trying to
204/// implement the regex, which is the useful reading of the constraint and the one the service
205/// enforces. Published as a string for the drift gate.
206pub const TAG_COMPONENT_PATTERN: &str = "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)";
207
208/// `RoleArn.min` — 20 characters, which is the shortest thing that can be an IAM role ARN.
209pub const MIN_ROLE_ARN_LEN: usize = 20;
210
211/// `RoleArn.max`.
212pub const MAX_ROLE_ARN_LEN: usize = 2048;
213
214/// `RoleArn.pattern`, as the model spells it.
215///
216/// Checked by [`crate::control::require_valid_role_arn`] on `CreateMicrovmImage.buildRoleArn`
217/// and `RunMicrovm.executionRoleArn`. The build role is the one that matters most: the create
218/// call happens **after** the artifact upload, so the service's rejection of a malformed role
219/// ARN costs the caller the upload — the exact ordering `create_image` is arranged to prevent,
220/// and the reason issue #24 called this one out by name.
221///
222/// Hand-matched rather than compiled, for the reason [`IMAGE_NAME_PATTERN`] gives, and the
223/// matcher is deliberately a **structural** check rather than a full regex implementation: see
224/// [`is_valid_role_arn`] on what it does and does not decide.
225pub const ROLE_ARN_PATTERN: &str = "arn:aws[a-z\\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+";
226
227/// `PortNumber.min`, and also `HooksPortInteger.min`. **1, not 0.**
228///
229/// Port 0 is what "let the kernel choose" means to a listener and it is not a port a proxy
230/// token or a hooks block can name. `with_port(0)` was representable and sent `{"port": 0}`
231/// (issue #24); [`crate::control::require_valid_port`] is what refuses it now.
232pub const MIN_PORT: u16 = 1;
233
234/// `PortNumber.max`, which equals [`MAX_HOOK_PORT`] and is a different shape.
235///
236/// Held apart for the reason [`MAX_NON_BLANK_LEN`] gives: `PortNumber` bounds
237/// `PortSpecification.port` and both ends of a `PortRange`, `HooksPortInteger` bounds
238/// `Hooks.port`, and they are two shapes AWS can move independently.
239pub const MAX_PORT: u16 = 65_535;
240
241/// `IdlePolicyMaxIdleDurationSecondsInteger.min` — 60 seconds, with no maximum.
242///
243/// # Why this has a constant now when it deliberately did not
244///
245/// The old comment here and on [`crate::control::RunMicrovmRequest::max_idle_sec`] justified
246/// having no guard on the grounds that `min` is one of the four keys in botocore's
247/// `VALIDATED_METADATA_ATTRS`, so botocore refuses it before the wire with a clear message.
248/// That is true of botocore and **false of this client**: `microvms-core` signs with
249/// `aws-sigv4` and sends with `reqwest`, and nothing in the dependency set imports
250/// `botocore/validate.py`. The reasoning was inherited from the deleted Python client, where it
251/// held, and it did not survive the port. Issue #24 measured the consequence:
252/// `max_idle_sec: 59` reached the wire.
253///
254/// Checked by [`crate::control::require_idle_duration`]. There is no maximum in the model, and
255/// the client adds none: a caller who wants a VM that never auto-suspends within its
256/// `maximumDurationInSeconds` says so with a large number, and the eight-hour ceiling on the
257/// VM's life is the real bound.
258pub const MIN_IDLE_DURATION_SEC: u32 = 60;
259
260/// `RunMicrovmRequestMaximumDurationInSecondsInteger.max` — eight hours, and the hard
261/// ceiling on any single VM's life. A longer session needs a second VM, not a larger
262/// number.
263pub const MAX_DURATION_SEC: u32 = 28_800;
264
265/// `MicrovmHooks*TimeoutInSecondsInteger.max` (run, resume, suspend, terminate).
266///
267/// See [`crate::hooks`] for the 60x gap and why it is two types.
268pub const MAX_MICROVM_HOOK_TIMEOUT_SEC: u32 = 60;
269
270/// `MicrovmImageHooks*TimeoutInSecondsInteger.max` (ready, validate).
271pub const MAX_IMAGE_HOOK_TIMEOUT_SEC: u32 = 3_600;
272
273/// `HooksPortInteger.max`.
274pub const MAX_HOOK_PORT: u32 = 65_535;
275
276/// The `Capability` enum, which is exactly this one value.
277///
278/// Which is why guest identity repair is a boolean intent flag rather than a
279/// capability list (TRAP-3): there is no way to ask for `CAP_SYS_ADMIN` alone.
280pub const CAPABILITIES: [&str; 1] = ["ALL"];
281
282/// The `Architecture` enum, which is exactly this one value: a MicroVM cannot be x86.
283///
284/// Load-bearing for cost (COST-9) as well as for requests — the Pricing API returns
285/// both ARM and non-ARM compute rates 17.9% apart, and only the ARM line can ever
286/// apply.
287pub const ARCHITECTURES: [&str; 1] = ["ARM_64"];
288
289/// `NetworkConnectorList.max` — the **VM-level** list, which `RunMicrovm` sends.
290pub const MAX_NETWORK_CONNECTORS: usize = 10;
291
292/// The **image-level** egress list's max, which is `1` and not `10`.
293///
294/// Six shapes in the model declare an image-level `egressNetworkConnectors` with
295/// `min: 0, max: 1` — `CreateMicrovmImageRequest`, `MicrovmImageVersionSummary`,
296/// `GetMicrovmImageVersionOutput`, `UpdateMicrovmImageVersionResponse`, and two more. The
297/// VM-level `NetworkConnectorList` above allows 10. Issue #24 named the hazard: reusing
298/// [`MAX_NETWORK_CONNECTORS`] for an image-level list is wrong by an order of magnitude, and
299/// wrong in the permissive direction, so the rejection would arrive from the service.
300///
301/// Pinned here rather than left as a comment because the version readback now deserializes
302/// that list ([`crate::control::ops::MicrovmImageVersionSummaryWire`]), so the two ceilings
303/// are both live in this crate and the drift gate can hold each against its own shape.
304pub const MAX_IMAGE_EGRESS_CONNECTORS: usize = 1;
305
306/// The `MicrovmImageVersionStatus` enum, in the model's order.
307///
308/// The values `UpdateMicrovmImageVersion` accepts and `GetMicrovmImageVersion` answers.
309/// Published so the drift gate compares them against the shape rather than against a literal
310/// inside the script — which is the gap the script's own comment admits for the five state
311/// enums, and this one is a request member, so a value the model dropped would be a call this
312/// client makes and the service refuses.
313///
314/// [`crate::control::ops::VersionStatus`] is the typed spelling; this array is what the gate
315/// reads, and the test at the bottom of that module asserts the two agree.
316pub const IMAGE_VERSION_STATUSES: [&str; 2] = ["ACTIVE", "INACTIVE"];
317
318/// The `HookState` enum, in the model's order. `DISABLED` first.
319///
320/// The two values every one of the six hook flags takes. Published so the gate compares them
321/// against the shape, and because this is a **request** enum on six members of every
322/// `CreateMicrovmImage`: a value the model dropped is a call this client makes and the service
323/// refuses, on six fields at once.
324///
325/// [`crate::control::ops::HookState`] is the typed spelling and is what the request carries.
326/// This array is what the gate reads, and the test in that module asserts the two agree — the
327/// same arrangement [`IMAGE_VERSION_STATUSES`] has with `ops::VersionStatus`.
328pub const HOOK_STATES: [&str; 2] = ["DISABLED", "ENABLED"];
329
330/// The `MicrovmState` enum, in the model's order.
331///
332/// # Why the state enums live here now
333///
334/// They were pinned against literals inside `scripts/check-model-drift.py`, which the script's own
335/// comment admitted: the gate verified the model against the *script*, with no reader in the
336/// client at all. Issue #24 named the consequence — a `MicrovmState` AWS adds fails the gate
337/// with no compile-time consequence for the polling loops in
338/// [`crate::control::microvm`] that branch on states, so the gate's failure and the loop's
339/// blindness were unrelated facts.
340///
341/// Moving the set here does not make a new state a compile error — a wire string cannot be
342/// exhaustively matched, and narrowing a *response* enum is the mistake
343/// [`crate::control::ops::MicrovmImageBuildSummaryWire::architecture`] documents at length. What
344/// it does buy is a reader in the crate that the loops' own sets are checked against: the tests
345/// below assert [`TERMINAL_STATES`] and [`DEAD_STATES`] are subsets of this, and
346/// [`crate::control::microvm::SUSPEND_WANTED`] is checked against it too. So a state removed or
347/// respelled by AWS fails the gate *and* fails a test naming the loop that branches on it.
348pub const MICROVM_STATES: [&str; 6] = [
349    "PENDING",
350    "RUNNING",
351    "SUSPENDING",
352    "SUSPENDED",
353    "TERMINATING",
354    "TERMINATED",
355];
356
357/// The `MicrovmImageState` enum, in the model's order.
358///
359/// What [`crate::control::image::Image::is_ready`] and `is_failed` are decided against. The
360/// three `*_FAILED` spellings are why `is_failed` is a substring test rather than a set: a
361/// fourth added later is still recognised as a failure rather than polled to the deadline, and
362/// the test below asserts that reading is still true of every member here.
363pub const IMAGE_STATES: [&str; 9] = [
364    "CREATING",
365    "CREATED",
366    "CREATE_FAILED",
367    "UPDATING",
368    "UPDATED",
369    "UPDATE_FAILED",
370    "DELETING",
371    "DELETE_FAILED",
372    "DELETED",
373];
374
375/// The `MicrovmImageVersionState` enum, in the model's order.
376///
377/// What `MicrovmImageVersionSummary.state` carries. Note it is **not** the same set as
378/// [`IMAGE_STATES`] and not the same as [`BUILD_STATES`] either, which is the reason all three
379/// are here: a habit that works for one is wrong for the others.
380pub const IMAGE_VERSION_STATES: [&str; 7] = [
381    "PENDING",
382    "IN_PROGRESS",
383    "SUCCESSFUL",
384    "FAILED",
385    "DELETING",
386    "DELETED",
387    "DELETE_FAILED",
388];
389
390/// The `BuildState` enum, in the model's order.
391///
392/// `PENDING` is the one TRAP-2's stall probe reads: **all** builds still `PENDING` past the
393/// stall grace is the `clientToken` replay signature. The probe compares against a literal
394/// today; the test below asserts that literal is a member of this set, which is the check that
395/// notices a respelling — the same class of bug as `buildState`/`state`, which was dead against
396/// live AWS for a review round.
397pub const BUILD_STATES: [&str; 4] = ["PENDING", "IN_PROGRESS", "SUCCESSFUL", "FAILED"];
398
399/// The `Chipset` enum, which is exactly this one value.
400///
401/// Now deserialized: [`crate::control::ops::MicrovmImageBuildSummaryWire::chipset`] and
402/// `GetImageBuildResponseWire::chipset` both read it, so the drift-gate observation in issue #24
403/// — "`Chipset` is drift-checked but deserialized nowhere" — was closed by the
404/// build-introspection work that added the model's five missing required members to those two
405/// shapes. Pinned here so the gate reads the value from the crate rather than from a literal in
406/// its own file.
407///
408/// Carried as a `String` on the wire types rather than narrowed to this set, for the reason
409/// those types state: a second chipset AWS adds must parse rather than fail a build readback.
410pub const CHIPSETS: [&str; 1] = ["GRAVITON"];
411
412/// `ResourcesList.max`.
413///
414/// Only one entry is accepted, so "give the VM two memory floors" is not a thing that
415/// can be asked.
416pub const MAX_RESOURCES: usize = 1;
417
418/// `Create/Run/UpdateMicrovmImageRequestClientTokenString.max`, all three 128.
419///
420/// Not a cosmetic cap. Run tokens fold in the image identifier, which is a full ARN,
421/// and an ap-northeast-1 ARN is long: the drift gate mints a token for the worst legal
422/// scope and asserts it fits, which is a check that found a real pre-launch bug.
423pub const MAX_CLIENT_TOKEN_LEN: usize = 128;
424
425/// The `MicrovmImageState` values that mean "built and usable", as the model spells
426/// them.
427///
428/// `UPDATED` is here even though this client never calls `UpdateMicrovmImage`: an
429/// image someone else updated is usable, and treating it as still-building is a
430/// 45-minute wait on a state that will never change.
431pub const MODEL_IMAGE_READY_STATES: [&str; 2] = ["CREATED", "UPDATED"];
432
433/// Spellings that are *not* in the 2025-09-09 `MicrovmImageState` enum, tolerated
434/// when the service answers with one.
435///
436/// Held apart from [`MODEL_IMAGE_READY_STATES`] so the drift gate can check the
437/// model-derived set *exactly* instead of being told that two of the three values it
438/// cannot find are fine. If a future model adds either, move it up. Kept at all
439/// because the service has answered differently across API versions, and a hard
440/// equality check on one spelling is how a working build looks like a stalled one.
441pub const TOLERATED_IMAGE_READY_STATES: [&str; 2] = ["ACTIVE", "AVAILABLE"];
442
443/// Terminal `MicrovmState` values.
444///
445/// Reaching any of them *before* RUNNING means the VM died during startup, which for a
446/// hook-serving daemon almost always means a lifecycle hook failed — and `stateReason`
447/// is where the answer is (TRAP-8).
448pub const TERMINAL_STATES: [&str; 4] = ["TERMINATED", "TERMINATING", "SUSPENDED", "SUSPENDING"];
449
450/// The subset of [`TERMINAL_STATES`] from which nothing comes back.
451///
452/// Separate because SUSPENDED is a death *before* RUNNING and an ordinary waypoint on
453/// the resume path — a resume that failed fast on SUSPENDED would fail on every
454/// resume, since that is the state the VM is in when the call is made.
455pub const DEAD_STATES: [&str; 2] = ["TERMINATED", "TERMINATING"];
456
457/// Whether `name` satisfies [`IMAGE_NAME_PATTERN`] and [`MAX_IMAGE_NAME_LEN`].
458///
459/// Hand-rolled: the pattern is `[a-zA-Z0-9-_]+`, four character ranges, and a regex
460/// crate for that is a dependency plus a compiled automaton plus a second place the
461/// pattern is written down. The length check is here too because a caller asking "is
462/// this name legal" means both constraints — they arrive from the same model shape and
463/// the service rejects on either.
464pub fn is_valid_image_name(name: &str) -> bool {
465    !name.is_empty()
466        && name.len() <= MAX_IMAGE_NAME_LEN
467        && name
468            .bytes()
469            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
470}
471
472/// Whether every character of `component` is in [`TAG_COMPONENT_PATTERN`]'s set.
473///
474/// **The length is not checked here**, unlike [`is_valid_image_name`], and the asymmetry is
475/// deliberate: a key and a value share this one pattern and have *different* ceilings (128
476/// against 256) and different minima (1 against 0). A combined check would need to know which
477/// side it was looking at, which is what [`crate::control::require_valid_tags`] does — it names
478/// the key or the value in its message, so a caller with twenty tags is told which one and
479/// which half.
480///
481/// # Why the character set and not the regex
482///
483/// The model's pattern is one `*`-quantified group, and an unanchored `*` matches the empty
484/// string and every prefix — as a predicate it rejects nothing. The service nonetheless refuses
485/// a key with a comma or a percent in it, so the constraint that has any content is the
486/// character *set*, and that is what this decides. Unicode classes rather than byte ranges,
487/// because `\p{L}` includes `é` and `日` and a tag key in a non-Latin script is a legitimate
488/// tag: a byte-range matcher would refuse it.
489///
490/// `\p{Z}` is the separator class, which includes the ordinary space — so `"cost centre"` is a
491/// legal tag key, unlike an image name.
492pub fn is_valid_tag_component(component: &str) -> bool {
493    component.chars().all(|c| {
494        c.is_alphabetic()
495            || c.is_numeric()
496            // `\p{Z}`: separators. `char::is_whitespace` also covers `\n` and `\t`, which are
497            // `\p{Cc}` control characters and outside the class — so they are excluded here
498            // rather than folded in, and a tag key with a newline in it is refused.
499            || matches!(c, ' ' | '\u{00a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}'
500                | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
501            || matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@')
502    })
503}
504
505/// Whether `arn` is structurally an IAM role ARN, per [`ROLE_ARN_PATTERN`].
506///
507/// # What this decides, and what it deliberately does not
508///
509/// The pattern is `arn:aws[a-z\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+`, and this checks
510/// every part of it: the `arn:aws` prefix with its optional partition suffix, the literal
511/// `:iam::`, **exactly twelve digits** of account id, and a `:role` segment followed by a
512/// non-empty name in the permitted character set.
513///
514/// The twelve digits are the part worth having. Every real mistake this catches is one of three:
515/// a role *name* passed where an ARN was wanted, an ARN for the wrong service (a Lambda function
516/// ARN, say), or an account id with a digit dropped — and the third is the one no eyeball catches
517/// and no other check would. It is also the reason this is not a `starts_with("arn:")` test the
518/// way [`crate::control::ControlPlane::managed_base_versions`]'s is: that one distinguishes an
519/// ARN from a bare name, and this one has a specific ARN grammar to hold a value to.
520///
521/// What it does not decide is whether the role **exists**, whether it is assumable, or whether it
522/// grants the `logs:*` on `/aws/lambda-microvms/*` that a build role needs. Those are the failures
523/// that actually happen most often, and none of them is knowable locally — IAM is not in this
524/// crate's dependency set. So this guard is narrow on purpose: it converts a class of malformed
525/// values into a local refusal and leaves the semantic failures to the service, which is the only
526/// party that can answer them.
527pub fn is_valid_role_arn(arn: &str) -> bool {
528    // `arn:aws` then the optional partition suffix, `[a-z\-]*`, up to the next `:`.
529    let Some(rest) = arn.strip_prefix("arn:aws") else {
530        return false;
531    };
532    let Some(colon) = rest.find(':') else {
533        return false;
534    };
535    let (partition_suffix, rest) = rest.split_at(colon);
536    if !partition_suffix
537        .bytes()
538        .all(|b| b.is_ascii_lowercase() || b == b'-')
539    {
540        return false;
541    }
542
543    // `:iam::` then exactly twelve digits then `:role`.
544    let Some(rest) = rest.strip_prefix(":iam::") else {
545        return false;
546    };
547    let Some(colon) = rest.find(':') else {
548        return false;
549    };
550    let (account, rest) = rest.split_at(colon);
551    if account.len() != 12 || !account.bytes().all(|b| b.is_ascii_digit()) {
552        return false;
553    }
554    let Some(name) = rest.strip_prefix(":role") else {
555        return false;
556    };
557    // `role/?` — the slash is optional in the pattern, and the name that follows is `+`, so it
558    // must be non-empty either way. `role/admin` and `roleadmin` both match the model's regex;
559    // only the first is a real ARN, and refusing the second would be this client being stricter
560    // than the service on a value it has no other reason to inspect.
561    let name = name.strip_prefix('/').unwrap_or(name);
562    !name.is_empty()
563        && name.bytes().all(|b| {
564            b.is_ascii_alphanumeric()
565                || matches!(b, b'_' | b'+' | b'=' | b',' | b'.' | b'@' | b'-' | b'/')
566        })
567}
568
569/// Every constant in this module as one JSON object, keyed as `sandbox.py` names them.
570///
571/// The drift gate's second source (TRAP-12). Emitted through `serde_json` rather than
572/// formatted by hand so the object is a function of the values above, which is the
573/// only version of this that cannot drift from them.
574///
575/// `MICROVM_REGIONS` is in here and is explicitly **not** model-backed — no service
576/// model states it and no API answers the question (see [`crate::region`]). It is
577/// published so the gate can at least check the Rust and Python lists against each
578/// other, which is the one comparison available for a measured-only value.
579///
580/// # A list of pairs rather than one `json!({...})` literal, and that is forced
581///
582/// It was one object literal until issue #24's constants took the count past 40, at which
583/// point `json!` fails to compile: "recursion limit reached while expanding
584/// `$crate::json_internal!`", because the macro recurses once per entry against the crate's
585/// 128-deep default. The two ways out were a crate-wide `#![recursion_limit]` bump — a global
586/// knob turned for one macro in one function — and this. Each entry is still `json!(CONST)`, so
587/// every value is a function of the `pub const` above it, which is the property the gate rests
588/// on.
589pub fn as_json() -> Value {
590    let entries: Vec<(&str, Value)> = vec![
591        ("MODEL_API_VERSION", json!(MODEL_API_VERSION)),
592        (
593            "MAX_RUN_HOOK_PAYLOAD_BYTES",
594            json!(MAX_RUN_HOOK_PAYLOAD_BYTES),
595        ),
596        (
597            "DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES",
598            json!(DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES),
599        ),
600        ("MAX_IMAGE_NAME_LEN", json!(MAX_IMAGE_NAME_LEN)),
601        ("IMAGE_NAME_PATTERN", json!(IMAGE_NAME_PATTERN)),
602        ("MAX_VERSION_LEN", json!(MAX_VERSION_LEN)),
603        ("VERSION_PATTERN", json!(VERSION_PATTERN)),
604        ("MAX_NON_BLANK_LEN", json!(MAX_NON_BLANK_LEN)),
605        ("NON_BLANK_PATTERN", json!(NON_BLANK_PATTERN)),
606        ("MAX_IDENTIFIER_LEN", json!(MAX_IDENTIFIER_LEN)),
607        ("MAX_IMAGE_ARN_LEN", json!(MAX_IMAGE_ARN_LEN)),
608        ("MAX_TAG_KEY_LEN", json!(MAX_TAG_KEY_LEN)),
609        ("MAX_TAG_VALUE_LEN", json!(MAX_TAG_VALUE_LEN)),
610        ("TAG_COMPONENT_PATTERN", json!(TAG_COMPONENT_PATTERN)),
611        ("MIN_ROLE_ARN_LEN", json!(MIN_ROLE_ARN_LEN)),
612        ("MAX_ROLE_ARN_LEN", json!(MAX_ROLE_ARN_LEN)),
613        ("ROLE_ARN_PATTERN", json!(ROLE_ARN_PATTERN)),
614        ("MIN_PORT", json!(MIN_PORT)),
615        ("MAX_PORT", json!(MAX_PORT)),
616        ("MIN_IDLE_DURATION_SEC", json!(MIN_IDLE_DURATION_SEC)),
617        ("MAX_DURATION_SEC", json!(MAX_DURATION_SEC)),
618        (
619            "MAX_MICROVM_HOOK_TIMEOUT_SEC",
620            json!(MAX_MICROVM_HOOK_TIMEOUT_SEC),
621        ),
622        (
623            "MAX_IMAGE_HOOK_TIMEOUT_SEC",
624            json!(MAX_IMAGE_HOOK_TIMEOUT_SEC),
625        ),
626        ("MAX_HOOK_PORT", json!(MAX_HOOK_PORT)),
627        ("CAPABILITIES", json!(CAPABILITIES)),
628        ("ARCHITECTURES", json!(ARCHITECTURES)),
629        ("MAX_NETWORK_CONNECTORS", json!(MAX_NETWORK_CONNECTORS)),
630        (
631            "MAX_IMAGE_EGRESS_CONNECTORS",
632            json!(MAX_IMAGE_EGRESS_CONNECTORS),
633        ),
634        ("IMAGE_VERSION_STATUSES", json!(IMAGE_VERSION_STATUSES)),
635        ("HOOK_STATES", json!(HOOK_STATES)),
636        ("MICROVM_STATES", json!(MICROVM_STATES)),
637        ("IMAGE_STATES", json!(IMAGE_STATES)),
638        ("IMAGE_VERSION_STATES", json!(IMAGE_VERSION_STATES)),
639        ("BUILD_STATES", json!(BUILD_STATES)),
640        ("CHIPSETS", json!(CHIPSETS)),
641        ("MAX_RESOURCES", json!(MAX_RESOURCES)),
642        ("MAX_CLIENT_TOKEN_LEN", json!(MAX_CLIENT_TOKEN_LEN)),
643        ("MODEL_IMAGE_READY_STATES", json!(MODEL_IMAGE_READY_STATES)),
644        (
645            "TOLERATED_IMAGE_READY_STATES",
646            json!(TOLERATED_IMAGE_READY_STATES),
647        ),
648        ("TERMINAL_STATES", json!(TERMINAL_STATES)),
649        ("DEAD_STATES", json!(DEAD_STATES)),
650        (
651            "MICROVM_REGIONS",
652            json!(
653                MICROVM_REGIONS
654                    .iter()
655                    .map(|region| region.as_str())
656                    .collect::<Vec<_>>()
657            ),
658        ),
659        (
660            "SIZE_CLASSES",
661            json!(
662                crate::sizing::SIZE_CLASSES
663                    .iter()
664                    .map(|row| json!({
665                        "baseline_mib": row.baseline_mib,
666                        "baseline_vcpu": row.baseline_vcpu,
667                        "peak_mib": row.peak_mib,
668                        "peak_vcpu": row.peak_vcpu,
669                    }))
670                    .collect::<Vec<_>>()
671            ),
672        ),
673    ];
674    Value::Object(
675        entries
676            .into_iter()
677            .map(|(key, value)| (key.to_string(), value))
678            .collect(),
679    )
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    /// The key set the drift gate reads, pinned.
687    ///
688    /// This is the guard for the one failure mode a rename here produces: the script
689    /// looks a key up, does not find it, and either crashes or — worse, depending on
690    /// how it is written — reports nothing disagreed. Compilation cannot catch that,
691    /// because the coupling is a string in another language's file.
692    #[test]
693    fn as_json_carries_every_key_the_drift_gate_reads() {
694        let emitted = as_json();
695        let object = emitted.as_object().expect("an object");
696        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
697        keys.sort_unstable();
698        assert_eq!(
699            keys,
700            [
701                "ARCHITECTURES",
702                "BUILD_STATES",
703                "CAPABILITIES",
704                "CHIPSETS",
705                "DEAD_STATES",
706                "DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES",
707                "HOOK_STATES",
708                "IMAGE_NAME_PATTERN",
709                "IMAGE_STATES",
710                "IMAGE_VERSION_STATES",
711                "IMAGE_VERSION_STATUSES",
712                "MAX_CLIENT_TOKEN_LEN",
713                "MAX_DURATION_SEC",
714                "MAX_HOOK_PORT",
715                "MAX_IDENTIFIER_LEN",
716                "MAX_IMAGE_ARN_LEN",
717                "MAX_IMAGE_EGRESS_CONNECTORS",
718                "MAX_IMAGE_HOOK_TIMEOUT_SEC",
719                "MAX_IMAGE_NAME_LEN",
720                "MAX_MICROVM_HOOK_TIMEOUT_SEC",
721                "MAX_NETWORK_CONNECTORS",
722                "MAX_NON_BLANK_LEN",
723                "MAX_PORT",
724                "MAX_RESOURCES",
725                "MAX_ROLE_ARN_LEN",
726                "MAX_RUN_HOOK_PAYLOAD_BYTES",
727                "MAX_TAG_KEY_LEN",
728                "MAX_TAG_VALUE_LEN",
729                "MAX_VERSION_LEN",
730                "MICROVM_REGIONS",
731                "MICROVM_STATES",
732                "MIN_IDLE_DURATION_SEC",
733                "MIN_PORT",
734                "MIN_ROLE_ARN_LEN",
735                "MODEL_API_VERSION",
736                "MODEL_IMAGE_READY_STATES",
737                "NON_BLANK_PATTERN",
738                "ROLE_ARN_PATTERN",
739                "SIZE_CLASSES",
740                "TAG_COMPONENT_PATTERN",
741                "TERMINAL_STATES",
742                "TOLERATED_IMAGE_READY_STATES",
743                "VERSION_PATTERN",
744            ]
745        );
746    }
747
748    /// The values, as literals, against the same numbers `sandbox.py` carries. Written
749    /// out rather than compared against the constants, so this test disagrees with a
750    /// changed constant instead of following it.
751    #[test]
752    fn the_emitted_values_are_the_measured_ones() {
753        let emitted = as_json();
754        assert_eq!(emitted["MODEL_API_VERSION"], "2025-09-09");
755        assert_eq!(emitted["MAX_RUN_HOOK_PAYLOAD_BYTES"], 4096);
756        assert_eq!(emitted["MAX_IMAGE_NAME_LEN"], 64);
757        assert_eq!(emitted["IMAGE_NAME_PATTERN"], "[a-zA-Z0-9-_]+");
758        assert_eq!(emitted["MAX_VERSION_LEN"], 2048);
759        assert_eq!(emitted["VERSION_PATTERN"], "[^\\s]+");
760        assert_eq!(emitted["MAX_NON_BLANK_LEN"], 2048);
761        assert_eq!(emitted["NON_BLANK_PATTERN"], "[^\\s]+");
762        assert_eq!(emitted["MAX_IDENTIFIER_LEN"], 256);
763        assert_eq!(emitted["MAX_IMAGE_ARN_LEN"], 2048);
764        assert_eq!(emitted["MAX_TAG_KEY_LEN"], 128);
765        assert_eq!(emitted["MAX_TAG_VALUE_LEN"], 256);
766        assert_eq!(
767            emitted["TAG_COMPONENT_PATTERN"],
768            "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)"
769        );
770        assert_eq!(emitted["MIN_ROLE_ARN_LEN"], 20);
771        assert_eq!(emitted["MAX_ROLE_ARN_LEN"], 2048);
772        assert_eq!(
773            emitted["ROLE_ARN_PATTERN"],
774            "arn:aws[a-z\\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+"
775        );
776        assert_eq!(emitted["MIN_PORT"], 1);
777        assert_eq!(emitted["MAX_PORT"], 65_535);
778        assert_eq!(emitted["MIN_IDLE_DURATION_SEC"], 60);
779        assert_eq!(emitted["MAX_DURATION_SEC"], 28_800);
780        assert_eq!(emitted["MAX_MICROVM_HOOK_TIMEOUT_SEC"], 60);
781        assert_eq!(emitted["MAX_IMAGE_HOOK_TIMEOUT_SEC"], 3_600);
782        assert_eq!(emitted["MAX_HOOK_PORT"], 65_535);
783        assert_eq!(emitted["CAPABILITIES"], json!(["ALL"]));
784        assert_eq!(emitted["ARCHITECTURES"], json!(["ARM_64"]));
785        assert_eq!(emitted["MAX_NETWORK_CONNECTORS"], 10);
786        // 1, not 10. The image-level list and the VM-level one differ by an order of
787        // magnitude, and this is the assertion that says so in one place.
788        assert_eq!(emitted["MAX_IMAGE_EGRESS_CONNECTORS"], 1);
789        assert_eq!(
790            emitted["IMAGE_VERSION_STATUSES"],
791            json!(["ACTIVE", "INACTIVE"])
792        );
793        assert_eq!(emitted["HOOK_STATES"], json!(["DISABLED", "ENABLED"]));
794        assert_eq!(
795            emitted["MICROVM_STATES"],
796            json!([
797                "PENDING",
798                "RUNNING",
799                "SUSPENDING",
800                "SUSPENDED",
801                "TERMINATING",
802                "TERMINATED"
803            ])
804        );
805        assert_eq!(
806            emitted["IMAGE_STATES"],
807            json!([
808                "CREATING",
809                "CREATED",
810                "CREATE_FAILED",
811                "UPDATING",
812                "UPDATED",
813                "UPDATE_FAILED",
814                "DELETING",
815                "DELETE_FAILED",
816                "DELETED"
817            ])
818        );
819        assert_eq!(
820            emitted["IMAGE_VERSION_STATES"],
821            json!([
822                "PENDING",
823                "IN_PROGRESS",
824                "SUCCESSFUL",
825                "FAILED",
826                "DELETING",
827                "DELETED",
828                "DELETE_FAILED"
829            ])
830        );
831        assert_eq!(
832            emitted["BUILD_STATES"],
833            json!(["PENDING", "IN_PROGRESS", "SUCCESSFUL", "FAILED"])
834        );
835        assert_eq!(emitted["CHIPSETS"], json!(["GRAVITON"]));
836        assert_eq!(emitted["MAX_RESOURCES"], 1);
837        assert_eq!(emitted["MAX_CLIENT_TOKEN_LEN"], 128);
838        assert_eq!(
839            emitted["TERMINAL_STATES"],
840            json!(["TERMINATED", "TERMINATING", "SUSPENDED", "SUSPENDING"])
841        );
842        assert_eq!(emitted["DEAD_STATES"], json!(["TERMINATED", "TERMINATING"]));
843        assert_eq!(
844            emitted["MODEL_IMAGE_READY_STATES"],
845            json!(["CREATED", "UPDATED"])
846        );
847        assert_eq!(
848            emitted["TOLERATED_IMAGE_READY_STATES"],
849            json!(["ACTIVE", "AVAILABLE"])
850        );
851        assert_eq!(
852            emitted["MICROVM_REGIONS"],
853            json!([
854                "us-east-1",
855                "us-east-2",
856                "us-west-2",
857                "eu-west-1",
858                "ap-northeast-1"
859            ])
860        );
861    }
862
863    /// The object survives a serialize/parse cycle unchanged, which is what the gate
864    /// does to it: the CLI prints it and the script calls `json.loads`. A value that
865    /// only compares equal in memory is a value the gate never sees.
866    #[test]
867    fn the_emitted_object_round_trips_through_a_string() {
868        let emitted = as_json();
869        let text = serde_json::to_string(&emitted).expect("serializes");
870        let parsed: Value = serde_json::from_str(&text).expect("parses");
871        assert_eq!(parsed, emitted);
872    }
873
874    /// The dead states are a subset of the terminal ones, which is the relationship
875    /// the two lists exist to express. A DEAD_STATES entry that is not terminal would
876    /// make the launch guard and the resume guard disagree about what SUSPENDED means.
877    #[test]
878    fn every_dead_state_is_also_a_terminal_state() {
879        for state in DEAD_STATES {
880            assert!(TERMINAL_STATES.contains(&state), "{state} is not terminal");
881        }
882        assert!(
883            !DEAD_STATES.contains(&"SUSPENDED"),
884            "SUSPENDED is terminal but not dead: it is the state a resume is called from"
885        );
886    }
887
888    /// **Issue #24's wrong-by-10x hazard.** The two connector ceilings are different
889    /// numbers, and the image-level one is the smaller.
890    ///
891    /// Stated as an inequality rather than only as two literals above, because the failure
892    /// mode is a future edit that *unifies* them — "there is only one connector limit, delete
893    /// the duplicate" — and the permissive direction is the one that reaches the wire. A
894    /// caller who applied `MAX_NETWORK_CONNECTORS` to an image's `egressNetworkConnectors`
895    /// would accept ten and have the service refuse two.
896    #[test]
897    fn the_image_level_connector_ceiling_is_one_and_not_the_vm_levels_ten() {
898        assert_eq!(MAX_IMAGE_EGRESS_CONNECTORS, 1);
899        assert_eq!(MAX_NETWORK_CONNECTORS, 10);
900        // Read out of `as_json()` rather than compared as two `const`s, because clippy's
901        // `assertions_on_constants` is right that a `const < const` assertion is decided at
902        // compile time and proves nothing at runtime. Going through the emitted object makes it
903        // a real comparison — and it is the object the drift gate reads, so this asserts the
904        // published pair rather than two literals a reader could unify.
905        let emitted = as_json();
906        let image = emitted["MAX_IMAGE_EGRESS_CONNECTORS"]
907            .as_u64()
908            .expect("a number");
909        let vm = emitted["MAX_NETWORK_CONNECTORS"]
910            .as_u64()
911            .expect("a number");
912        assert!(
913            image < vm,
914            "collapsing the two would be permissive by 10x on the image-level list: \
915             {image} against {vm}"
916        );
917    }
918
919    /// The version statuses are exactly the two the model declares, spelled uppercase.
920    ///
921    /// The typed request-side spelling is checked against this array in
922    /// `crate::control::ops`, so the two cannot drift; here what is pinned is the pair itself
923    /// and the fact that neither is a lowercase or a past-tense variant of the other.
924    #[test]
925    fn the_two_version_statuses_are_active_and_inactive() {
926        assert_eq!(IMAGE_VERSION_STATUSES, ["ACTIVE", "INACTIVE"]);
927        for status in IMAGE_VERSION_STATUSES {
928            assert_eq!(status, status.to_uppercase(), "{status}");
929        }
930        assert!(
931            !IMAGE_VERSION_STATUSES.contains(&"DISABLED"),
932            "DISABLED is HookState's spelling, not this enum's — mixing them is a \
933             ValidationException on the only member the update request has"
934        );
935    }
936
937    /// The ready-state sets are disjoint, so the gate can check the model-derived one
938    /// exactly. An overlap would mean a tolerated spelling was being presented as
939    /// model-backed.
940    #[test]
941    fn the_model_and_tolerated_ready_states_do_not_overlap() {
942        for state in TOLERATED_IMAGE_READY_STATES {
943            assert!(
944                !MODEL_IMAGE_READY_STATES.contains(&state),
945                "{state} is claimed as both model-backed and tolerated"
946            );
947        }
948    }
949
950    /// The pattern's four ranges and nothing else. The two rejected characters are the
951    /// separators a caller reaching for a namespaced name writes first, which is why
952    /// they are named.
953    #[test]
954    fn an_image_name_takes_letters_digits_hyphen_and_underscore_only() {
955        assert!(is_valid_image_name("agentd-conformance_01"));
956        assert!(is_valid_image_name("A"));
957        assert!(is_valid_image_name(&"a".repeat(MAX_IMAGE_NAME_LEN)));
958
959        assert!(!is_valid_image_name(""), "min is 1");
960        assert!(
961            !is_valid_image_name(&"a".repeat(MAX_IMAGE_NAME_LEN + 1)),
962            "65 characters is one past the ceiling"
963        );
964        for rejected in [
965            "my.image",
966            "team/image",
967            "my image",
968            "img!",
969            "imagé",
970            "img\n",
971        ] {
972            assert!(!is_valid_image_name(rejected), "{rejected}");
973        }
974    }
975
976    /// A multi-byte character cannot slip past the length check by being counted as
977    /// one: the model's max is on bytes, and the character class rejects it anyway.
978    #[test]
979    fn a_multibyte_name_is_refused_by_the_character_class_not_by_luck() {
980        assert!(!is_valid_image_name("é"));
981        assert!(!is_valid_image_name("日本語"));
982    }
983
984    /// **Issue #24's documentation trap, pinned as a comparison.** The model's own prose says
985    /// 16,384 and its shape says 4096, and the shape is the one that is enforced.
986    ///
987    /// The pair is asserted rather than only commented because the failure mode is a future
988    /// reader "correcting" [`MAX_RUN_HOOK_PAYLOAD_BYTES`] from the model's documentation string —
989    /// which they would be able to cite while doing it. This test is what makes that a red build
990    /// rather than a plausible-looking commit, and the 4x relationship is asserted so the shape of
991    /// the hazard is in the test and not only in a doc comment.
992    ///
993    /// **Guard proof.** Set `MAX_RUN_HOOK_PAYLOAD_BYTES` to 16_384 and this fails on the
994    /// inequality *and* on the ratio; delete `DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES` and it does not
995    /// compile.
996    #[test]
997    fn the_models_own_prose_claims_four_times_the_ceiling_its_shape_states() {
998        let emitted = as_json();
999        let real = emitted["MAX_RUN_HOOK_PAYLOAD_BYTES"]
1000            .as_u64()
1001            .expect("a number");
1002        let documented = emitted["DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES"]
1003            .as_u64()
1004            .expect("a number");
1005        assert_eq!(real, 4096, "the shape, which is what the service enforces");
1006        assert_eq!(
1007            documented, 16_384,
1008            "the model's documentation string on RunMicrovmRequest.runHookPayload"
1009        );
1010        assert!(
1011            documented > real,
1012            "the model's prose is permissive against its own shape: {documented} against {real}"
1013        );
1014        assert_eq!(
1015            documented / real,
1016            4,
1017            "wrong by 4x, in the direction that tells a caller four times as much secret \
1018             material fits as actually does"
1019        );
1020    }
1021
1022    /// **The model's identifier contradiction, pinned.** A legal `MicrovmImageArn` can be longer
1023    /// than a legal `MicrovmImageIdentifier`, so the service may answer with a value it would
1024    /// refuse as a request.
1025    ///
1026    /// Held as an inequality because the fact is a *relationship* between two shapes and no
1027    /// single shape states it. If AWS ever narrows `MicrovmImageArn` to 256 this goes red, which
1028    /// is the signal that the paragraph in [`MAX_IDENTIFIER_LEN`] about resolving the
1029    /// contradiction should be deleted.
1030    ///
1031    /// The second half is the reason it is theoretical in practice: a real ARN in the
1032    /// longest-named MicroVM region is nowhere near 256, and that arithmetic is measured here
1033    /// rather than asserted as a habit.
1034    #[test]
1035    fn a_legal_image_arn_can_be_longer_than_a_legal_identifier() {
1036        let emitted = as_json();
1037        let identifier = emitted["MAX_IDENTIFIER_LEN"].as_u64().expect("a number");
1038        let arn = emitted["MAX_IMAGE_ARN_LEN"].as_u64().expect("a number");
1039        assert!(
1040            arn > identifier,
1041            "the model permits a {arn}-character image ARN and a {identifier}-character \
1042             identifier, so a legal response value can be an illegal request value"
1043        );
1044
1045        // The worst legal real ARN: the longest region name, a twelve-digit account, and a
1046        // 64-character image name. Built rather than written as a literal, so the arithmetic is
1047        // the test's rather than mine.
1048        let longest_region = MICROVM_REGIONS
1049            .iter()
1050            .map(|region| region.as_str().len())
1051            .max()
1052            .expect("five regions");
1053        let worst = format!(
1054            "arn:aws:lambda:{}:{}:microvm-image:{}",
1055            "x".repeat(longest_region),
1056            "9".repeat(12),
1057            "a".repeat(MAX_IMAGE_NAME_LEN),
1058        );
1059        assert!(
1060            worst.len() <= MAX_IDENTIFIER_LEN,
1061            "the worst legal image ARN is {} characters, which must fit the {MAX_IDENTIFIER_LEN} \
1062             identifier bound or the contradiction would be reachable rather than theoretical",
1063            worst.len(),
1064        );
1065    }
1066
1067    /// Every state set the client's loops branch on is a subset of the model's enum for it.
1068    ///
1069    /// **This is what moving the five state enums out of the drift script buys.** They were
1070    /// pinned against literals inside `scripts/check-model-drift.py`, so a `MicrovmState` AWS
1071    /// respelled failed the gate with no consequence for the code that branches on it (issue
1072    /// #24). With the sets here, the same respelling fails this test as well, and this test names
1073    /// the loop.
1074    ///
1075    /// **Guard proof.** Respell one member of `MICROVM_STATES` — `"SUSPENDED"` to
1076    /// `"SUSPEND_PENDING"`, say — and this fails naming `TERMINAL_STATES`, `DEAD_STATES`, and
1077    /// `SUSPEND_WANTED` in turn. Drop `"PENDING"` from `BUILD_STATES` and the stall-probe
1078    /// assertion fails.
1079    #[test]
1080    fn every_state_set_the_polling_loops_use_is_a_subset_of_the_models_enum() {
1081        for state in TERMINAL_STATES {
1082            assert!(
1083                MICROVM_STATES.contains(&state),
1084                "TERMINAL_STATES has {state}, which is not a MicrovmState — \
1085                 ControlPlane::wait_for_running fails fast on it and would now never fire"
1086            );
1087        }
1088        for state in DEAD_STATES {
1089            assert!(
1090                MICROVM_STATES.contains(&state),
1091                "DEAD_STATES has {state}, which is not a MicrovmState — the resume path passes \
1092                 this set as its fail-fast list"
1093            );
1094        }
1095        for state in crate::control::microvm::SUSPEND_WANTED {
1096            assert!(
1097                MICROVM_STATES.contains(&state),
1098                "SUSPEND_WANTED has {state}, which is not a MicrovmState — a suspend would wait \
1099                 for a state the service cannot report and time out"
1100            );
1101        }
1102        for state in MODEL_IMAGE_READY_STATES {
1103            assert!(
1104                IMAGE_STATES.contains(&state),
1105                "MODEL_IMAGE_READY_STATES has {state}, which is not a MicrovmImageState — \
1106                 Image::is_ready would never answer true for it and a built image would poll to \
1107                 the 45-minute deadline"
1108            );
1109        }
1110        for state in TOLERATED_IMAGE_READY_STATES {
1111            assert!(
1112                !IMAGE_STATES.contains(&state),
1113                "{state} is in the model's enum now, so it is no longer a tolerated legacy \
1114                 spelling — promote it to MODEL_IMAGE_READY_STATES"
1115            );
1116        }
1117        // TRAP-2's stall probe compares `build_state == "PENDING"` against a literal. This is the
1118        // check that notices the literal going stale, which is the same class of bug as
1119        // `buildState`/`state` — dead against live AWS while its unit test passed.
1120        assert!(
1121            BUILD_STATES.contains(&"PENDING"),
1122            "the stall probe's replay signature is 'every build still PENDING'"
1123        );
1124        // `Image::is_failed` is a substring test on FAILED rather than a set, so that a fourth
1125        // failure spelling is still recognised. That reading has to stay true of the enum.
1126        let failed: Vec<&str> = IMAGE_STATES
1127            .iter()
1128            .copied()
1129            .filter(|state| state.contains("FAILED"))
1130            .collect();
1131        assert_eq!(
1132            failed,
1133            ["CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED"],
1134            "the three spellings the substring test covers"
1135        );
1136    }
1137
1138    /// The tag character set: letters and digits in any script, spaces, and eight punctuation
1139    /// marks. Commas, `#`, `%`, and newlines are out.
1140    ///
1141    /// The non-Latin cases are the ones a byte-range matcher would have got wrong, and they are
1142    /// legitimate tags — `\p{L}` is every letter, not every ASCII letter. The newline case is the
1143    /// one `char::is_whitespace` would have got wrong in the other direction: `\n` is `\p{Cc}`,
1144    /// a control character, not the `\p{Z}` separator class the pattern names.
1145    #[test]
1146    fn a_tag_component_takes_any_script_and_eight_punctuation_marks() {
1147        for accepted in [
1148            "owner",
1149            "cost-centre",
1150            "cost centre",
1151            "team/agents",
1152            "a.b:c=d+e-f@g_h",
1153            "日本語",
1154            "café",
1155            "123",
1156            "",
1157        ] {
1158            assert!(is_valid_tag_component(accepted), "{accepted:?}");
1159        }
1160        for rejected in [
1161            "a,b",
1162            "a#b",
1163            "50%",
1164            "line\nbreak",
1165            "tab\there",
1166            "a!b",
1167            "a(b",
1168        ] {
1169            assert!(!is_valid_tag_component(rejected), "{rejected:?}");
1170        }
1171    }
1172
1173    /// The role-ARN matcher, and the twelve-digit account id is the part that earns it.
1174    ///
1175    /// Three real mistakes and one near-miss: a role name where an ARN was wanted, an ARN for
1176    /// another service, an account id with a digit dropped, and a `roleadmin` with no slash —
1177    /// which the model's regex accepts, so this does too rather than being stricter than the
1178    /// service on a value it has no other reason to inspect.
1179    #[test]
1180    fn a_role_arn_needs_twelve_account_digits_and_an_iam_role_path() {
1181        for accepted in [
1182            "arn:aws:iam::123456789012:role/build",
1183            "arn:aws:iam::123456789012:role/service-role/path/to/role",
1184            "arn:aws-us-gov:iam::123456789012:role/build",
1185            "arn:aws-cn:iam::123456789012:role/build",
1186            "arn:aws:iam::123456789012:role/name+with=every,allowed.char@here_x-y",
1187            // No slash: the model's `role/?` makes it optional, so this matches the pattern.
1188            "arn:aws:iam::123456789012:roleadmin",
1189        ] {
1190            assert!(is_valid_role_arn(accepted), "{accepted}");
1191        }
1192
1193        for rejected in [
1194            // A role *name*, which is the most common mistake.
1195            "bonk-sandbox-microvm-build",
1196            // The right shape for the wrong service.
1197            "arn:aws:lambda:us-east-1:123456789012:function:handler",
1198            // Eleven digits. The one nothing else catches.
1199            "arn:aws:iam::12345678901:role/build",
1200            // Thirteen.
1201            "arn:aws:iam::1234567890123:role/build",
1202            // Non-digits where the account goes.
1203            "arn:aws:iam::abcdefghijkl:role/build",
1204            // A region where IAM has none, which is what a copied Lambda ARN looks like.
1205            "arn:aws:iam:us-east-1:123456789012:role/build",
1206            // Nothing after `:role`.
1207            "arn:aws:iam::123456789012:role",
1208            "arn:aws:iam::123456789012:role/",
1209            // A user, not a role.
1210            "arn:aws:iam::123456789012:user/laith",
1211            // Uppercase partition, which the pattern's `[a-z\-]*` forbids.
1212            "arn:AWS:iam::123456789012:role/build",
1213            "",
1214            "arn:",
1215        ] {
1216            assert!(!is_valid_role_arn(rejected), "{rejected}");
1217        }
1218    }
1219
1220    /// The real conformance-account role ARNs match, which is what stops this guard from being a
1221    /// pattern that refuses every legitimate value.
1222    ///
1223    /// Written out as the account's actual ARNs rather than as a synthetic `role/build`: a
1224    /// matcher that accepts a fixture and refuses production is the failure mode a guard like this
1225    /// has, and it fires after the artifact upload.
1226    #[test]
1227    fn the_conformance_accounts_own_role_arns_are_accepted() {
1228        for real in [
1229            "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
1230            "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution",
1231        ] {
1232            assert!(is_valid_role_arn(real), "{real}");
1233            assert!(real.len() >= MIN_ROLE_ARN_LEN, "{real}");
1234            assert!(real.len() <= MAX_ROLE_ARN_LEN, "{real}");
1235        }
1236    }
1237
1238    /// The two port constants bracket what a `u16` can hold, with the floor at 1 and not 0.
1239    ///
1240    /// `MAX_PORT` equalling `u16::MAX` is the fact that makes the ceiling closed by the type and
1241    /// the floor the only live half — which is what the guard's docs claim, asserted rather than
1242    /// stated.
1243    #[test]
1244    fn the_port_floor_is_one_and_the_ceiling_is_what_a_u16_holds() {
1245        assert_eq!(MIN_PORT, 1, "0 is 'let the kernel choose', not a port");
1246        assert_eq!(MAX_PORT, u16::MAX);
1247        assert_eq!(
1248            u32::from(MAX_PORT),
1249            MAX_HOOK_PORT,
1250            "PortNumber and HooksPortInteger agree today; they are two shapes and this is where \
1251             it is noticed if they stop"
1252        );
1253    }
1254
1255    /// The two `[^\s]+` shapes agree today, and the point of two constants is that this test is
1256    /// what says so rather than one constant making it unaskable.
1257    #[test]
1258    fn the_version_and_non_blank_shapes_agree_today_and_are_still_two_constants() {
1259        assert_eq!(MAX_VERSION_LEN, MAX_NON_BLANK_LEN);
1260        assert_eq!(VERSION_PATTERN, NON_BLANK_PATTERN);
1261        // Read out of the emitted object rather than compared as two `const`s, for the reason
1262        // the connector-ceiling test gives: a `const == const` assertion is decided at compile
1263        // time and proves nothing about the values the gate reads.
1264        let emitted = as_json();
1265        assert_eq!(emitted["MAX_VERSION_LEN"], emitted["MAX_NON_BLANK_LEN"]);
1266        assert_eq!(emitted["VERSION_PATTERN"], emitted["NON_BLANK_PATTERN"]);
1267    }
1268}