microvms_core/control/artifact.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The build artifact: a zip of the daemon binary and a Dockerfile.
3//!
4//! What `CreateMicrovmImage`'s `codeArtifact.uri` points at (`sandbox.py:523`
5//! `build_artifact`). Two entries, and both of them carry a measured constraint.
6//!
7//! # The execute bit has to be in the zip entry
8//!
9//! A build that copies a non-executable binary produces an image whose `CMD` fails, and
10//! the failure surfaces as a **run-hook timeout** — which says nothing about permissions,
11//! and sends the reader to look at the daemon's startup path instead of at the archive.
12//! So the `agentd` entry sets mode `0o755` explicitly rather than inheriting whatever the
13//! host file had.
14//!
15//! # The agent token is never in here (TRAP-5's other half)
16//!
17//! The artifact becomes a **shared image snapshot**: every MicroVM launched from the image
18//! sees the same bytes. A per-VM secret in there is a per-VM secret shared with every VM.
19//! So the token travels through `runHookPayload` at launch instead, and this function has
20//! no parameter that could carry one — see [`crate::control::microvm`]. The test at the
21//! bottom of this file scans the produced zip's raw bytes for a token value to prove the
22//! path stays closed, which is a byte scan rather than an API review because the leak
23//! would be a *value* appearing somewhere, not a parameter being declared.
24
25use std::io::Write as _;
26use std::time::Duration;
27
28use crate::error::{Error, ErrorKind};
29
30/// The Dockerfile entry's name, which the build looks for by convention.
31const DOCKERFILE_ENTRY: &str = "Dockerfile";
32
33/// The daemon entry's name, matching the `CMD ["/agentd"]` the Dockerfile sets.
34const AGENTD_ENTRY: &str = "agentd";
35
36/// The mode the daemon entry carries.
37///
38/// See the module docs: a non-executable binary here becomes a run-hook timeout later.
39const AGENTD_MODE: u32 = 0o755;
40
41/// Zips `binary` with `dockerfile` into the bytes `codeArtifact.uri` will point at.
42///
43/// `binary` is the daemon's bytes rather than a path, so the caller owns the read and this
44/// function has no filesystem behaviour to stub in a test.
45pub fn build_artifact(binary: &[u8], dockerfile: &str) -> Result<Vec<u8>, Error> {
46 use zip::write::{SimpleFileOptions, ZipWriter};
47
48 let mut writer = ZipWriter::new(std::io::Cursor::new(Vec::new()));
49 let deflated =
50 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
51
52 let mut write_entry = |name: &str, bytes: &[u8], options: SimpleFileOptions| {
53 writer
54 .start_file(name, options)
55 .and_then(|()| writer.write_all(bytes).map_err(Into::into))
56 .map_err(|error| {
57 Error::new(
58 ErrorKind::Unexpected,
59 format!("could not add {name} to the build artifact: {error}"),
60 )
61 })
62 };
63
64 write_entry(DOCKERFILE_ENTRY, dockerfile.as_bytes(), deflated)?;
65 write_entry(AGENTD_ENTRY, binary, deflated.unix_permissions(AGENTD_MODE))?;
66
67 let bytes = writer
68 .finish()
69 .map_err(|error| {
70 Error::new(
71 ErrorKind::Unexpected,
72 format!("could not finish the build artifact: {error}"),
73 )
74 })?
75 .into_inner();
76 Ok(bytes)
77}
78
79/// The sha256 of the artifact's two inputs, as lowercase hex.
80///
81/// # What is hashed, and why not the zip
82///
83/// The **inputs** — the daemon binary's bytes and the Dockerfile text — rather than the
84/// bytes [`build_artifact`] produces. The zip is a container: its byte identity depends on
85/// the `zip` crate's version, its compression level, and its header defaults, so an
86/// upgraded dependency would silently change every image name and orphan every reuse. The
87/// inputs are what a build actually consumes, and two builds with equal inputs produce
88/// interchangeable images — which is the property content-addressed reuse rests on.
89///
90/// Each input is length-prefixed before hashing, so `(binary="ab", dockerfile="c")` and
91/// `(binary="a", dockerfile="bc")` are different hashes rather than one concatenation.
92pub fn artifact_content_hash(binary: &[u8], dockerfile: &str) -> String {
93 use sha2::{Digest as _, Sha256};
94
95 let mut hasher = Sha256::new();
96 hasher.update((binary.len() as u64).to_be_bytes());
97 hasher.update(binary);
98 hasher.update((dockerfile.len() as u64).to_be_bytes());
99 hasher.update(dockerfile.as_bytes());
100 const_hex::encode(hasher.finalize())
101}
102
103/// A base image: the platform ARN, the Dockerfile `FROM` that pairs with it, and whether
104/// it declares a `WORKDIR`.
105///
106/// All three together because the first two **must** agree and used to be able to
107/// disagree: the Python client's `DEFAULT_BASE_IMAGE` named the managed base for
108/// `baseImageArn` while `default_dockerfile` hardcoded an unrelated registry literal in
109/// its `FROM`, so changing either left the other pointing somewhere else
110/// (`sandbox.py:410-444`). Pairing them means a caller selects one thing and both fields
111/// follow.
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct BaseImage {
114 /// Goes into `baseImageArn` — the platform's managed base, not a registry ref.
115 pub name: String,
116 /// Goes into the Dockerfile `FROM` — the registry ref measured alongside `name`.
117 pub docker_ref: String,
118 /// What `docker inspect` reports for `WorkingDir`. Empty means it declares none.
119 ///
120 /// A field rather than a lookup because a caller with a purpose-built image is the
121 /// only one who can say what their image declares, and this client cannot read it
122 /// without pulling the manifest.
123 pub working_dir: String,
124}
125
126impl BaseImage {
127 /// The managed base every `docs/PLATFORM.md` measurement from 2026-08-06 onward used,
128 /// paired with the registry ref those same builds used as `FROM`.
129 ///
130 /// `working_dir` is empty, and that is measured rather than assumed:
131 /// `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave `WorkingDir`
132 /// empty (2026-08-05), which is what makes [`require_workdir`] necessary.
133 pub fn al2023() -> Self {
134 Self {
135 name: "al2023-1".to_string(),
136 docker_ref: "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal".to_string(),
137 working_dir: String::new(),
138 }
139 }
140
141 /// The `baseImageArn` for this base in `region`.
142 ///
143 /// # `microvm-image:<name>`, with a colon, and it is not just the managed base
144 ///
145 /// This was the only place in the repo building the colon form; the fakes and the
146 /// encoding test all used `microvm-image/<name>`, so the repo held two beliefs about the
147 /// shape of the identifier every image call takes. The model's `TaggableResource` pattern
148 /// admits only `(capacity-provider|network-connector|microvm-image):[a-zA-Z0-9-_]+`, and
149 /// a live read settled it: `ListMicrovmImages` in us-east-1 returns
150 /// `arn:aws:lambda:us-east-1:<account>:microvm-image:coding-agents-on-bedrock` for a
151 /// customer image, and `GetMicrovmImage` accepts exactly that (measured 2026-08-15).
152 ///
153 /// So the colon is right for **both** the managed base and a customer image; there is no
154 /// two-form rule, and this comment used to imply there was. The slash form fails as
155 /// `AccessDeniedException` rather than as a validation error, because IAM evaluates the
156 /// malformed ARN as a resource with no matching policy — a permissions message for a
157 /// resource that exists.
158 pub fn arn(&self, region: &crate::region::Region) -> String {
159 format!(
160 "arn:aws:lambda:{}:aws:microvm-image:{}",
161 region.as_str(),
162 self.name
163 )
164 }
165}
166
167impl Default for BaseImage {
168 fn default() -> Self {
169 Self::al2023()
170 }
171}
172
173/// A Dockerfile that makes the daemon the container `CMD`.
174///
175/// `ENTRYPOINT []` plus `CMD ["/agentd"]` is the deployment invariant the trust boundary
176/// rests on: it is what guarantees no task workload runs before the platform's run hook
177/// lands. It is also what makes an omitted `cwd` inherit the image `WORKDIR`, since the
178/// daemon's own cwd is the image's.
179///
180/// The `FROM` is derived from `base` rather than written here, so it cannot disagree with
181/// the `baseImageArn` the create call sends.
182///
183/// The invariant is *unenforced*: a base image that starts its own background process
184/// before bootstrap breaks it, and enforcing that belongs to whoever builds the image
185/// (`docs/PROTOCOL.md`, "Trust boundary").
186pub fn default_dockerfile(port: u16, workdir: Option<&str>, base: &BaseImage) -> String {
187 let mut lines = vec![
188 format!("FROM {}", base.docker_ref),
189 "COPY agentd /agentd".to_string(),
190 "RUN chmod 0755 /agentd".to_string(),
191 ];
192 if let Some(workdir) = workdir.filter(|dir| !dir.is_empty()) {
193 lines.push(format!("RUN mkdir -p {workdir}"));
194 lines.push(format!("WORKDIR {workdir}"));
195 }
196 lines.extend([
197 format!("ENV AGENTD_PORT={port}"),
198 "ENV AGENTD_LOG=info".to_string(),
199 format!("EXPOSE {port}"),
200 "ENTRYPOINT []".to_string(),
201 r#"CMD ["/agentd"]"#.to_string(),
202 String::new(),
203 ]);
204 lines.join("\n")
205}
206
207/// The image ref in a Dockerfile's first `FROM`, or `None` when it has none.
208///
209/// Deliberately loose on whitespace and case, and it ignores `--platform=` and `AS name`
210/// decoration, because the check exists to catch a base that disagrees rather than to
211/// validate Dockerfile syntax.
212///
213/// Split-and-scan rather than a regex: the pattern is "first token after FROM on a line
214/// whose first word is FROM", which `split_whitespace` states more directly than a
215/// pattern string would. (`regex-lite` is available if this ever grows.)
216pub fn dockerfile_from_ref(dockerfile: &str) -> Option<&str> {
217 for line in dockerfile.lines() {
218 let mut words = line.split_whitespace();
219 let Some(first) = words.next() else { continue };
220 if !first.eq_ignore_ascii_case("FROM") {
221 continue;
222 }
223 // Skip flag decoration such as `--platform=linux/arm64`.
224 return words.find(|word| !word.starts_with("--"));
225 }
226 None
227}
228
229/// Rejects working-directory inheritance when nothing declares one.
230///
231/// Measured 2026-08-05: `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave
232/// `WorkingDir` empty, so "inherit the image WORKDIR" inherits `/` and every relative path
233/// in the caller's commands resolves somewhere they did not mean.
234///
235/// Rejected rather than warned because the symptom appears in the **guest, one build cycle
236/// later**, as commands running in the wrong directory rather than as anything about
237/// `WORKDIR`.
238pub fn require_workdir(base: &BaseImage, dockerfile: Option<&str>) -> Result<(), Error> {
239 if !base.working_dir.is_empty() {
240 return Ok(());
241 }
242 let declares_workdir = dockerfile.is_some_and(|text| {
243 text.lines().any(|line| {
244 let mut words = line.split_whitespace();
245 words
246 .next()
247 .is_some_and(|first| first.eq_ignore_ascii_case("WORKDIR"))
248 && words.next().is_some()
249 })
250 });
251 if declares_workdir {
252 return Ok(());
253 }
254 Err(Error::invalid_arg(format!(
255 "inherit_workdir was requested but base image {:?} declares no WorkingDir and the \
256 Dockerfile sets none. Most public ARM64 base images leave it empty \
257 (docs/PLATFORM.md, 'Most public ARM64 base images have no WORKDIR'), so there is \
258 nothing to inherit and every relative path would resolve against `/`. Pass a workdir \
259 to default_dockerfile, or set WORKDIR in your own Dockerfile.",
260 base.name
261 )))
262}
263
264/// The port in a Dockerfile's last `ENV AGENTD_PORT=`, or `None` when it sets none.
265///
266/// Last rather than first: a later `ENV` of the same name wins at build time, so the last
267/// one is what the daemon reads. Accepts `ENV AGENTD_PORT=9000` and the legacy
268/// `ENV AGENTD_PORT 9000` spelling, since both set the variable and a guard that only
269/// understands one form passes the file it cannot parse.
270///
271/// Split-and-scan for the same reason [`dockerfile_from_ref`] is: `split_whitespace`
272/// states the pattern more directly than a regex string would here.
273pub fn dockerfile_agentd_port(dockerfile: &str) -> Option<u16> {
274 let mut found = None;
275 for line in dockerfile.lines() {
276 let mut words = line.split_whitespace();
277 let Some(first) = words.next() else { continue };
278 if !first.eq_ignore_ascii_case("ENV") {
279 continue;
280 }
281 let Some(assignment) = words.next() else {
282 continue;
283 };
284 let value = match assignment.split_once('=') {
285 Some(("AGENTD_PORT", value)) => value,
286 Some(_) => continue,
287 // `ENV AGENTD_PORT 9000`: the value is the next word.
288 None if assignment == "AGENTD_PORT" => match words.next() {
289 Some(value) => value,
290 None => continue,
291 },
292 None => continue,
293 };
294 // An unparseable value is not this guard's business: the daemon keeps its default
295 // for one (`agentd/src/config.rs:118`), so there is no disagreement to report.
296 if let Ok(port) = value.parse() {
297 found = Some(port);
298 }
299 }
300 found
301}
302
303/// Rejects a Dockerfile whose `AGENTD_PORT` disagrees with the port the create call sends
304/// as `hooks.port`.
305///
306/// The two are set independently — `hooks.port` comes from `ControlPlane::port`, the
307/// variable from the caller's own Dockerfile — and the platform calls its build-time
308/// `ready`/`validate` hooks on the port in the create call. A guest listening elsewhere
309/// answers nothing, so the build fails.
310///
311/// Rejected rather than warned because the failure points away from its cause: the docker
312/// build succeeds, the log group holds a clean build *and* the daemon's own "agentd
313/// listening" line, and the image still lands in `CREATE_FAILED`. Neither
314/// `GetMicrovmImage` nor the build log names the port — only
315/// `GetMicrovmImageVersion`'s `hooks.port` does, compared by hand against the Dockerfile.
316///
317/// A Dockerfile that sets no `AGENTD_PORT` is checked against
318/// [`DEFAULT_AGENT_PORT`](crate::control::DEFAULT_AGENT_PORT) rather than passed, because
319/// silence is not neutral: `Config::from_env` keeps its own default for an unset variable
320/// (`agentd/src/config.rs:118`, default `agentd/src/config.rs:84`), so the guest listens on
321/// 9000 whether or not the Dockerfile mentions a port. Against a client that changed its
322/// port, the absent variable produces exactly the measured failure — and produces it for a
323/// caller who never typed a port anywhere, which is the harder version to diagnose.
324pub fn require_matching_agentd_port(port: u16, dockerfile: &str) -> Result<(), Error> {
325 // An unparseable value lands here too, and belongs here: the daemon warns and keeps the
326 // same default (`agentd/src/config.rs:174-179`), so both spellings of "the Dockerfile
327 // named no usable port" have the same consequence in the guest.
328 let Some(found) = dockerfile_agentd_port(dockerfile) else {
329 if port == super::DEFAULT_AGENT_PORT {
330 return Ok(());
331 }
332 return Err(Error::invalid_arg(format!(
333 "this client sends hooks.port={port} but the Dockerfile sets no usable \
334 AGENTD_PORT, so the daemon will listen on its own default of {default}. These \
335 must agree: the platform calls the build-time ready/validate hooks on the port \
336 in the create call, and a daemon listening on {default} answers none of them — \
337 the docker build succeeds, the daemon logs that it is listening, and the image \
338 still fails with CREATE_FAILED naming no port. Add ENV AGENTD_PORT={port} to \
339 the Dockerfile, or build it with default_dockerfile, which derives the value \
340 from the same port.",
341 default = super::DEFAULT_AGENT_PORT,
342 )));
343 };
344 if found == port {
345 return Ok(());
346 }
347 Err(Error::invalid_arg(format!(
348 "the Dockerfile sets ENV AGENTD_PORT={found} but this client sends hooks.port={port}. \
349 These must agree: the platform calls the build-time ready/validate hooks on the port \
350 in the create call, and a daemon listening on {found} answers none of them — the \
351 docker build succeeds, the daemon logs that it is listening, and the image still \
352 fails with CREATE_FAILED naming no port. Set ENV AGENTD_PORT={port} in the \
353 Dockerfile, or build it with default_dockerfile, which derives the value from the \
354 same port."
355 )))
356}
357
358/// The seconds in a Dockerfile's last `ENV AGENTD_SSE_KEEPALIVE_SECS=`, or `None` when it
359/// sets none.
360///
361/// Same shape and same reasoning as [`dockerfile_agentd_port`]: last assignment wins, both
362/// `ENV` spellings, and an unparseable value reads as absent because the daemon warns and
363/// keeps its default for one (`agentd/src/config.rs:174-179`).
364pub fn dockerfile_env_u64(dockerfile: &str, key: &str) -> Option<u64> {
365 let mut found = None;
366 for line in dockerfile.lines() {
367 let mut words = line.split_whitespace();
368 let Some(first) = words.next() else { continue };
369 if !first.eq_ignore_ascii_case("ENV") {
370 continue;
371 }
372 let Some(assignment) = words.next() else {
373 continue;
374 };
375 let value = match assignment.split_once('=') {
376 Some((name, value)) if name == key => value,
377 Some(_) => continue,
378 None if assignment == key => match words.next() {
379 Some(value) => value,
380 None => continue,
381 },
382 None => continue,
383 };
384 if let Ok(parsed) = value.parse() {
385 found = Some(parsed);
386 }
387 }
388 found
389}
390
391/// Rejects a Dockerfile whose `AGENTD_SSE_KEEPALIVE_SECS` is not shorter than the client's
392/// stream idle timeout.
393///
394/// The fourth pair of this shape, found by sweeping for the other three. The daemon's SSE
395/// keepalive interval and the client's tolerance for silence are set in different
396/// repositories of truth — the interval in the caller's Dockerfile
397/// (`agentd/src/config.rs:139`, default 15s at `:95`), the tolerance in
398/// [`DEFAULT_STREAM_IDLE_TIMEOUT`](crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT),
399/// which is 60s *because* it is four times that 15. Raise the interval past the tolerance
400/// and every attached stream treats a healthy connection as dead, reconnects
401/// `max_reconnects` times, and fails.
402///
403/// The failure names both numbers and misattributes one of them: the client reports that the
404/// stream "went silent for 60s, longer than the keepalive interval"
405/// (`microvms-core/src/session/http.rs:383-387`), where 60 is its own timeout and the
406/// keepalive interval is the number it does not know. So a reader is told the keepalive was
407/// exceeded by a message that prints the wrong value for it, one build cycle after the
408/// Dockerfile that caused it.
409///
410/// Equality is refused, not just excess: an interval exactly equal to the timeout races.
411pub fn require_keepalive_under_idle_timeout(
412 idle_timeout: Duration,
413 dockerfile: &str,
414) -> Result<(), Error> {
415 let Some(secs) = dockerfile_env_u64(dockerfile, "AGENTD_SSE_KEEPALIVE_SECS") else {
416 // Absent is safe here, unlike the port: the daemon's default of 15s is already
417 // under every idle timeout this client will use, and a client that shortens its
418 // timeout below 15s is not something a Dockerfile can be blamed for.
419 return Ok(());
420 };
421 if secs < idle_timeout.as_secs() {
422 return Ok(());
423 }
424 Err(Error::invalid_arg(format!(
425 "the Dockerfile sets ENV AGENTD_SSE_KEEPALIVE_SECS={secs} but this client treats a \
426 stream as dead after {timeout}s of silence. The keepalive must be shorter: the \
427 daemon sends nothing between events except that keepalive, so an interval of \
428 {secs}s makes a healthy stream look dead, and every attach reconnects until it \
429 gives up. The error it raises then reports the client's own {timeout}s as though it \
430 were the keepalive interval, so it names neither the Dockerfile nor {secs}. Leave \
431 the variable unset for the daemon's 15s default, or keep it under {timeout}.",
432 timeout = idle_timeout.as_secs(),
433 )))
434}
435
436/// The value of a Dockerfile's last `ENTRYPOINT`, or `None` when it sets none.
437///
438/// Last rather than first for the reason [`dockerfile_agentd_port`] gives: a later
439/// instruction of the same name wins at build time, so the last one is what the container
440/// runs. The value is the raw rest of the line — this scan finds a disagreement, it does
441/// not parse Dockerfile syntax.
442pub fn dockerfile_entrypoint(dockerfile: &str) -> Option<&str> {
443 last_instruction_value(dockerfile, "ENTRYPOINT")
444}
445
446/// The value of a Dockerfile's last `CMD`, or `None` when it sets none.
447pub fn dockerfile_cmd(dockerfile: &str) -> Option<&str> {
448 last_instruction_value(dockerfile, "CMD")
449}
450
451/// The rest of the last line whose first word is `keyword`, trimmed, or `None`.
452fn last_instruction_value<'a>(dockerfile: &'a str, keyword: &str) -> Option<&'a str> {
453 let mut found = None;
454 for line in dockerfile.lines() {
455 let trimmed = line.trim_start();
456 let Some(first) = trimmed.split_whitespace().next() else {
457 continue;
458 };
459 if first.eq_ignore_ascii_case(keyword) {
460 found = Some(trimmed[first.len()..].trim());
461 }
462 }
463 found
464}
465
466/// Whether an instruction value is the empty exec form — `[]`, with any spacing.
467fn is_empty_exec_form(value: &str) -> bool {
468 let mut meaningful = value.chars().filter(|c| !c.is_whitespace());
469 meaningful.next() == Some('[') && meaningful.next() == Some(']') && meaningful.next().is_none()
470}
471
472/// Rejects a Dockerfile whose image would never run the daemon: no `CMD`, or an
473/// `ENTRYPOINT` that swallows it.
474///
475/// The artifact unconditionally carries the daemon as entry `agentd` ([`build_artifact`]),
476/// and the deployment invariant is `ENTRYPOINT []` plus `CMD ["/agentd"]` — see
477/// [`default_dockerfile`]. A caller Dockerfile with no `CMD` runs the base's own default
478/// instead, and a non-empty `ENTRYPOINT` turns any `CMD` into that entrypoint's arguments
479/// rather than the process. Either way the daemon never starts, the build-time
480/// `ready`/`validate` hooks go unanswered, and the image lands in `CREATE_FAILED` — or the
481/// launch dies as a run-hook timeout — with nothing naming `CMD`, `ENTRYPOINT`, or the
482/// artifact entry.
483///
484/// Weak-form on purpose: it does **not** check that the `CMD` names a path anything was
485/// copied to. That would be Dockerfile interpretation rather than agreement checking, and
486/// the two mistakes people actually make are the two refused here. The unenforceable half —
487/// a base image that starts its own background process before bootstrap — stays with
488/// whoever builds the image (`docs/PROTOCOL.md`, "Trust boundary").
489pub fn require_daemon_cmd(dockerfile: &str) -> Result<(), Error> {
490 if let Some(entrypoint) = dockerfile_entrypoint(dockerfile)
491 && !is_empty_exec_form(entrypoint)
492 {
493 return Err(Error::invalid_arg(format!(
494 "the Dockerfile sets ENTRYPOINT {entrypoint}, which makes any CMD its arguments \
495 rather than the container's process — so the daemon this client uploads never \
496 starts. The build succeeds anyway: the ready/validate hooks just go unanswered \
497 and the image fails as CREATE_FAILED, or the launch dies as a run-hook timeout, \
498 and neither symptom names ENTRYPOINT. Set `ENTRYPOINT []` alongside \
499 `CMD [\"/agentd\"]`, or build with default_dockerfile, which sets both."
500 )));
501 }
502 match dockerfile_cmd(dockerfile) {
503 Some(cmd) if !cmd.is_empty() && !is_empty_exec_form(cmd) => Ok(()),
504 _ => Err(Error::invalid_arg(
505 "the Dockerfile has no CMD, so the container runs the base image's default \
506 process and the daemon this client uploads never starts. The build succeeds \
507 anyway: the ready/validate hooks just go unanswered and the image fails as \
508 CREATE_FAILED, or the launch dies as a run-hook timeout, and neither symptom \
509 names CMD. Add `ENTRYPOINT []` and `CMD [\"/agentd\"]`, or build with \
510 default_dockerfile, which sets both."
511 .to_string(),
512 )),
513 }
514}
515
516/// Rejects a Dockerfile whose `FROM` is not the selected base image.
517///
518/// The build runs the Dockerfile *on top of* the base named in `baseImageArn`, so the two
519/// disagreeing produces an image built from something other than the platform base whose
520/// behaviour every `docs/PLATFORM.md` measurement describes — and nothing in the result
521/// says so.
522pub fn require_matching_from(base: &BaseImage, dockerfile: &str) -> Result<(), Error> {
523 let Some(found) = dockerfile_from_ref(dockerfile) else {
524 // No FROM at all is not this check's business: the build will say so, and a
525 // Dockerfile validator is not what this is.
526 return Ok(());
527 };
528 if found == base.docker_ref {
529 return Ok(());
530 }
531 Err(Error::invalid_arg(format!(
532 "the Dockerfile's FROM is {found:?} but base image {:?} pairs with {:?}. These must \
533 agree: baseImageArn and the FROM select the same base, and a mismatch builds against \
534 a base none of the measured platform behaviour applies to. Use default_dockerfile \
535 with this base, or pass a BaseImage whose docker_ref matches.",
536 base.name, base.docker_ref
537 )))
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 /// The archive holds exactly the two entries the build expects, named as the build
545 /// looks for them.
546 #[test]
547 fn the_artifact_holds_a_dockerfile_and_the_daemon() {
548 let bytes = build_artifact(b"\x7fELF fake daemon", "FROM scratch\n").expect("zips");
549 let mut archive =
550 zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
551
552 let mut names: Vec<String> = (0..archive.len())
553 .map(|index| archive.by_index(index).expect("entry").name().to_string())
554 .collect();
555 names.sort();
556 assert_eq!(names, ["Dockerfile", "agentd"]);
557 }
558
559 /// The daemon entry carries mode 0755, and the Dockerfile entry does not have to.
560 ///
561 /// Read back out of the archive rather than asserted on the constant, because the
562 /// constant being right and the zip entry carrying it are two different facts — and it
563 /// is the second one that turns into a run-hook timeout.
564 ///
565 /// The permission bits are masked out of the returned mode: `zip` reports the whole
566 /// Unix mode, so the entry reads as `0o100755` — `S_IFREG | 0o755`. Comparing the
567 /// unmasked value against `0o755` fails while the archive is perfectly correct, which
568 /// is a test that would have sent someone to debug the writer.
569 #[test]
570 fn the_daemon_entry_carries_the_execute_bit() {
571 let bytes = build_artifact(b"binary", "FROM scratch\n").expect("zips");
572 let mut archive =
573 zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
574 let mode = archive
575 .by_name("agentd")
576 .expect("the daemon entry")
577 .unix_mode()
578 .expect("a mode was recorded");
579 assert_eq!(
580 mode & 0o777,
581 AGENTD_MODE,
582 "a non-executable binary surfaces as a run-hook timeout, not a permission error \
583 (mode was {mode:o})"
584 );
585 assert!(
586 mode & 0o111 != 0,
587 "the execute bit is the whole point: {mode:o}"
588 );
589 }
590
591 /// The bytes round-trip. A zip that holds a truncated or re-encoded binary produces an
592 /// image whose CMD fails for a reason no message names.
593 #[test]
594 fn the_daemon_bytes_survive_the_round_trip() {
595 use std::io::Read as _;
596
597 // Deliberately includes a NUL and a high byte, which is what a real ELF has and
598 // what a text-mode write would mangle.
599 let binary: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
600 let bytes = build_artifact(&binary, "FROM scratch\n").expect("zips");
601 let mut archive =
602 zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
603 let mut read = Vec::new();
604 archive
605 .by_name("agentd")
606 .expect("entry")
607 .read_to_end(&mut read)
608 .expect("reads");
609 assert_eq!(read, binary);
610 }
611
612 /// **AC-2-3, the byte-scan guard.** The agent token must not appear anywhere in the
613 /// artifact's raw bytes.
614 ///
615 /// A byte scan rather than an API review, because the leak this guards against is a
616 /// *value* turning up somewhere — in the Dockerfile as an `ENV`, in a baked config
617 /// file, in a stray argument — not a parameter being declared. Scanning the compressed
618 /// bytes and the decompressed entries both, since deflate would hide a literal from a
619 /// naive scan of the archive.
620 ///
621 /// **Falsification** — add the token to the Dockerfile (`ENV AGENT_TOKEN=…`, the
622 /// plausible mistake) and the decompressed scan fails.
623 #[test]
624 fn the_artifact_never_carries_the_agent_token() {
625 use std::io::Read as _;
626
627 let token = "s3cr3t-agent-token-do-not-bake-me";
628 let dockerfile = default_dockerfile(9000, Some("/opt/work"), &BaseImage::al2023());
629 assert!(
630 !dockerfile.contains(token),
631 "the default Dockerfile must not mention a token"
632 );
633
634 let bytes = build_artifact(b"binary", &dockerfile).expect("zips");
635 assert!(
636 !bytes
637 .windows(token.len())
638 .any(|window| window == token.as_bytes()),
639 "the token appears in the artifact's raw bytes"
640 );
641
642 let mut archive =
643 zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
644 for index in 0..archive.len() {
645 let mut entry = archive.by_index(index).expect("entry");
646 let name = entry.name().to_string();
647 let mut content = Vec::new();
648 entry.read_to_end(&mut content).expect("reads");
649 assert!(
650 !content
651 .windows(token.len())
652 .any(|window| window == token.as_bytes()),
653 "the token appears inside {name} — the image snapshot is shared by every VM \
654 launched from it, so a token here is a token shared with every VM"
655 );
656 }
657 }
658
659 /// The content hash is a pure function of the two inputs: equal inputs agree, either
660 /// input changing changes it, and the boundary between the two inputs is part of the
661 /// identity.
662 ///
663 /// The boundary case is the one worth spelling out: without length prefixes,
664 /// `("ab", "c")` and `("a", "bc")` hash the same concatenation — and a Dockerfile
665 /// edit could then collide with a binary edit, serving a stale image for changed
666 /// inputs, which is the exact hazard the hash exists to close.
667 #[test]
668 fn the_content_hash_follows_the_inputs_and_only_the_inputs() {
669 let hash = artifact_content_hash(b"binary-bytes", "FROM scratch\n");
670 assert_eq!(
671 hash,
672 artifact_content_hash(b"binary-bytes", "FROM scratch\n")
673 );
674 assert_eq!(hash.len(), 64, "sha256 as lowercase hex");
675 assert!(
676 hash.chars()
677 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
678 );
679
680 assert_ne!(
681 hash,
682 artifact_content_hash(b"other-bytes", "FROM scratch\n")
683 );
684 assert_ne!(
685 hash,
686 artifact_content_hash(b"binary-bytes", "FROM scratch\nRUN true\n")
687 );
688 assert_ne!(
689 artifact_content_hash(b"ab", "c"),
690 artifact_content_hash(b"a", "bc"),
691 "the input boundary is part of the identity"
692 );
693 }
694
695 /// The Dockerfile's `FROM` comes from the base image, so the two cannot disagree.
696 #[test]
697 fn the_default_dockerfile_derives_its_from_from_the_base_image() {
698 let base = BaseImage::al2023();
699 let dockerfile = default_dockerfile(9000, None, &base);
700 assert_eq!(
701 dockerfile_from_ref(&dockerfile),
702 Some(base.docker_ref.as_str())
703 );
704 assert!(dockerfile.contains(r#"CMD ["/agentd"]"#));
705 assert!(
706 dockerfile.contains("ENTRYPOINT []"),
707 "the trust boundary rests on this line"
708 );
709 assert!(dockerfile.contains("ENV AGENTD_PORT=9000"));
710 }
711
712 /// A workdir is written as both a `mkdir` and a `WORKDIR`, and is absent when none was
713 /// asked for. An empty string counts as none, since that is what an unset value looks
714 /// like coming from a CLI flag.
715 #[test]
716 fn a_workdir_is_created_and_set_or_absent_entirely() {
717 let base = BaseImage::al2023();
718 let with = default_dockerfile(9000, Some("/opt/baked-workdir"), &base);
719 assert!(with.contains("RUN mkdir -p /opt/baked-workdir"));
720 assert!(with.contains("WORKDIR /opt/baked-workdir"));
721
722 for none in [None, Some("")] {
723 let without = default_dockerfile(9000, none, &base);
724 assert!(!without.contains("WORKDIR"), "{without}");
725 }
726 }
727
728 /// `FROM` parsing tolerates the decoration a real Dockerfile carries: lowercase,
729 /// leading whitespace, a `--platform` flag, and an `AS` alias.
730 #[test]
731 fn the_from_parser_ignores_decoration_rather_than_validating_syntax() {
732 assert_eq!(dockerfile_from_ref("FROM alpine\n"), Some("alpine"));
733 assert_eq!(dockerfile_from_ref(" from alpine:3\n"), Some("alpine:3"));
734 assert_eq!(
735 dockerfile_from_ref("FROM --platform=linux/arm64 alpine AS build\n"),
736 Some("alpine")
737 );
738 assert_eq!(dockerfile_from_ref("RUN echo from nowhere\n"), None);
739 assert_eq!(dockerfile_from_ref(""), None);
740 }
741
742 /// A `FROM` that disagrees with the base image is refused, and the message names both
743 /// refs — the one found and the one expected — because "these must agree" without both
744 /// values leaves the caller to guess which to change.
745 #[test]
746 fn a_dockerfile_from_that_disagrees_with_the_base_is_refused() {
747 let base = BaseImage::al2023();
748 let error = require_matching_from(&base, "FROM ubuntu:24.04\nCOPY agentd /agentd\n")
749 .expect_err("ubuntu is not the managed base");
750 assert_eq!(error.kind(), ErrorKind::InvalidArg);
751 let message = error.to_string();
752 assert!(message.contains("ubuntu:24.04"), "{message}");
753 assert!(message.contains(&base.docker_ref), "{message}");
754 assert!(
755 message.contains("none of the measured platform behaviour applies"),
756 "{message}"
757 );
758
759 require_matching_from(&base, &default_dockerfile(9000, None, &base))
760 .expect("the derived Dockerfile agrees with its own base");
761 }
762
763 /// A Dockerfile with no `FROM` is not this check's business: the build will say so, and
764 /// refusing here would be a Dockerfile validator rather than an agreement check.
765 #[test]
766 fn a_dockerfile_with_no_from_is_left_to_the_build() {
767 require_matching_from(&BaseImage::al2023(), "COPY agentd /agentd\n")
768 .expect("no FROM is not a disagreement");
769 }
770
771 /// The measured case, reproduced from the Dockerfile that spent it: a hand-written
772 /// guest Dockerfile carrying `ENV AGENTD_PORT=8080` — the plausible port, and the wrong
773 /// one — against a client sending `hooks.port=9000`. The build succeeds, the daemon logs
774 /// `agentd listening`, and the image lands in `CREATE_FAILED` naming no port.
775 #[test]
776 fn a_dockerfile_port_that_disagrees_with_the_hook_port_is_refused() {
777 let error = require_matching_agentd_port(
778 crate::control::DEFAULT_AGENT_PORT,
779 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
780 COPY agentd /agentd\n\
781 ENV AGENTD_PORT=8080\n\
782 EXPOSE 8080\n\
783 CMD [\"/agentd\"]\n",
784 )
785 .expect_err("8080 is not the port the create call sends");
786 assert_eq!(error.kind(), ErrorKind::InvalidArg);
787 let message = error.to_string();
788 assert!(message.contains("8080"), "{message}");
789 assert!(message.contains("9000"), "{message}");
790 // The remedy is what a reader acts on, and the diagnostic's value is naming the
791 // symptom that points away from the cause.
792 assert!(message.contains("CREATE_FAILED"), "{message}");
793 assert!(message.contains("ready/validate"), "{message}");
794
795 require_matching_agentd_port(
796 crate::control::DEFAULT_AGENT_PORT,
797 &default_dockerfile(
798 crate::control::DEFAULT_AGENT_PORT,
799 Some("/work"),
800 &BaseImage::al2023(),
801 ),
802 )
803 .expect("the derived Dockerfile agrees with the port it was derived from");
804 }
805
806 /// Both `ENV` spellings set the variable, so a guard reading only `KEY=VALUE` would pass
807 /// the legacy form it could not parse. The last assignment wins, as it does at build
808 /// time.
809 #[test]
810 fn the_port_scan_reads_both_env_spellings_and_takes_the_last() {
811 assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=9000\n"), Some(9000));
812 assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT 8080\n"), Some(8080));
813 assert_eq!(dockerfile_agentd_port("env agentd_port=7000\n"), None);
814 assert_eq!(
815 dockerfile_agentd_port("ENV AGENTD_PORT=8080\nENV AGENTD_PORT=9000\n"),
816 Some(9000),
817 );
818 assert_eq!(dockerfile_agentd_port("ENV AGENTD_LOG=info\n"), None);
819 // Neither a missing variable nor an unparseable one is a disagreement: the daemon
820 // keeps its own default for both (`agentd/src/config.rs:118`).
821 assert_eq!(
822 dockerfile_agentd_port("FROM x\nCOPY agentd /agentd\n"),
823 None
824 );
825 assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=nine\n"), None);
826 require_matching_agentd_port(
827 crate::control::DEFAULT_AGENT_PORT,
828 "FROM x\nCOPY agentd /agentd\n",
829 )
830 .expect("no variable agrees with the default, which is what the daemon will use");
831 }
832
833 /// The other half of the pair, and the harder one to diagnose: a Dockerfile that names
834 /// no port at all against a client that moved off the default. Silence is not neutral —
835 /// `Config::from_env` keeps `9000` for an unset variable — so the guest listens on 9000
836 /// while the hooks are dialled on the client's port, which is the measured failure with
837 /// nothing in the Dockerfile to point at.
838 #[test]
839 fn a_dockerfile_naming_no_port_is_refused_when_the_client_moved_off_the_default() {
840 let error = require_matching_agentd_port(
841 8080,
842 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
843 COPY agentd /agentd\n\
844 CMD [\"/agentd\"]\n",
845 )
846 .expect_err("a silent Dockerfile leaves the daemon on 9000, not on 8080");
847 assert_eq!(error.kind(), ErrorKind::InvalidArg);
848 let message = error.to_string();
849 assert!(message.contains("8080"), "{message}");
850 assert!(
851 message.contains(&crate::control::DEFAULT_AGENT_PORT.to_string()),
852 "the daemon's default is the value the reader has to learn: {message}"
853 );
854 assert!(message.contains("CREATE_FAILED"), "{message}");
855 assert!(message.contains("ready/validate"), "{message}");
856
857 // An unparseable value has the same consequence in the guest as no value, so it is
858 // refused for the same reason rather than passed as "not this guard's business".
859 require_matching_agentd_port(8080, "FROM x\nENV AGENTD_PORT=nine\n")
860 .expect_err("a value the daemon cannot parse leaves it on its own default");
861
862 // The default-port client is the common case and stays silent: the Dockerfile that
863 // says nothing and the client that changed nothing already agree.
864 require_matching_agentd_port(
865 crate::control::DEFAULT_AGENT_PORT,
866 "FROM x\nCOPY agentd /agentd\n",
867 )
868 .expect("silence agrees with the default");
869 }
870
871 /// The fourth pair of the `FROM`/`WORKDIR`/`AGENTD_PORT` shape, found by sweeping for
872 /// the others: a keepalive interval the client's silence tolerance is shorter than. The
873 /// resulting error prints the client's own timeout as though it were the keepalive, so
874 /// the number a reader would search for never appears.
875 #[test]
876 fn a_keepalive_at_or_over_the_client_idle_timeout_is_refused() {
877 let timeout = crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT;
878 assert_eq!(timeout.as_secs(), 60, "the guard's arithmetic assumes this");
879
880 let error =
881 require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=90\n")
882 .expect_err("90s of scheduled silence exceeds a 60s tolerance");
883 assert_eq!(error.kind(), ErrorKind::InvalidArg);
884 let message = error.to_string();
885 assert!(message.contains("90"), "{message}");
886 assert!(message.contains("60"), "{message}");
887
888 // Equality races rather than passing: the timeout fires on the same tick the
889 // keepalive is due.
890 require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=60\n")
891 .expect_err("an interval equal to the tolerance is a race, not a margin");
892
893 require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS 30\n")
894 .expect("30s leaves a margin, in the legacy spelling");
895 // The daemon's own default is 15s, so silence is safe for this pair.
896 require_keepalive_under_idle_timeout(timeout, "FROM x\nCOPY agentd /agentd\n")
897 .expect("an unset keepalive leaves the daemon at 15s");
898 require_keepalive_under_idle_timeout(
899 timeout,
900 &default_dockerfile(
901 crate::control::DEFAULT_AGENT_PORT,
902 Some("/work"),
903 &BaseImage::al2023(),
904 ),
905 )
906 .expect("the derived Dockerfile sets no keepalive");
907 }
908
909 /// **Issue #46, both refusable halves.** A Dockerfile with no `CMD` builds an image
910 /// that runs the base's default process; a non-empty `ENTRYPOINT` turns the `CMD` into
911 /// its arguments. Either way the daemon the artifact carries never starts, and the
912 /// failure surfaces as `CREATE_FAILED` or a run-hook timeout naming neither
913 /// instruction — so the message must name both the instruction and that symptom.
914 #[test]
915 fn a_dockerfile_that_never_runs_the_daemon_is_refused_naming_the_symptom() {
916 // No CMD at all: the base's default applies, which is not /agentd.
917 let error = require_daemon_cmd(
918 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
919 COPY agentd /agentd\n\
920 ENV AGENTD_PORT=9000\n",
921 )
922 .expect_err("no CMD means the daemon never starts");
923 assert_eq!(error.kind(), ErrorKind::InvalidArg);
924 let message = error.to_string();
925 assert!(message.contains("no CMD"), "{message}");
926 assert!(message.contains("run-hook timeout"), "{message}");
927 assert!(message.contains("CREATE_FAILED"), "{message}");
928
929 // A non-empty ENTRYPOINT swallows the CMD as its arguments.
930 let error = require_daemon_cmd(
931 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
932 COPY agentd /agentd\n\
933 ENTRYPOINT [\"/bin/sh\", \"-c\"]\n\
934 CMD [\"/agentd\"]\n",
935 )
936 .expect_err("a non-empty ENTRYPOINT makes CMD its arguments");
937 assert_eq!(error.kind(), ErrorKind::InvalidArg);
938 let message = error.to_string();
939 assert!(message.contains("ENTRYPOINT"), "{message}");
940 assert!(message.contains("run-hook timeout"), "{message}");
941
942 // An empty CMD is the same absence in different spelling.
943 require_daemon_cmd("FROM x\nCMD []\n").expect_err("CMD [] runs nothing");
944 }
945
946 /// The shapes that must pass: the deployment invariant itself, in exec and spaced
947 /// spellings, and the invariant's `ENTRYPOINT []` with any interior whitespace. The
948 /// derived Dockerfile passes its own guard, which is what keeps the default path
949 /// unaffected.
950 #[test]
951 fn the_deployment_invariant_passes_the_daemon_cmd_guard() {
952 require_daemon_cmd(&default_dockerfile(
953 9000,
954 Some("/work"),
955 &BaseImage::al2023(),
956 ))
957 .expect("the derived Dockerfile is the invariant");
958 require_daemon_cmd("FROM x\nENTRYPOINT []\nCMD [\"/agentd\"]\n").expect("the invariant");
959 require_daemon_cmd("FROM x\nENTRYPOINT [ ]\nCMD [\"/agentd\"]\n")
960 .expect("interior whitespace is still the empty exec form");
961 require_daemon_cmd("FROM x\ncmd [\"/agentd\"]\n").expect("case-insensitive, no ENTRYPOINT");
962 // Last instruction wins, as at build time: a later empty ENTRYPOINT un-swallows.
963 require_daemon_cmd("FROM x\nENTRYPOINT [\"/bin/sh\"]\nENTRYPOINT []\nCMD [\"/agentd\"]\n")
964 .expect("the last ENTRYPOINT is the one the build uses");
965 // And the reverse ordering is refused for the same reason.
966 require_daemon_cmd("FROM x\nENTRYPOINT []\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n")
967 .expect_err("the last ENTRYPOINT is non-empty");
968 }
969
970 /// Workdir inheritance is refused when neither the base nor the Dockerfile declares
971 /// one, and the message says where the symptom would have appeared — in the guest, a
972 /// build cycle later.
973 #[test]
974 fn inheriting_a_workdir_nothing_declares_is_refused() {
975 let base = BaseImage::al2023();
976 assert!(base.working_dir.is_empty(), "the measured case");
977
978 let error = require_workdir(&base, None).expect_err("nothing declares a workdir");
979 assert_eq!(error.kind(), ErrorKind::InvalidArg);
980 let message = error.to_string();
981 assert!(message.contains("no WorkingDir"), "{message}");
982 assert!(message.contains("nothing to inherit"), "{message}");
983 assert!(message.contains("docs/PLATFORM.md"), "{message}");
984 }
985
986 /// It is accepted when *either* side declares one — the Dockerfile's `WORKDIR` or the
987 /// base image's own `working_dir`. Both directions, because a check that only read the
988 /// Dockerfile would refuse a caller on a purpose-built image that declares one.
989 #[test]
990 fn either_the_dockerfile_or_the_base_may_supply_the_workdir() {
991 let base = BaseImage::al2023();
992 require_workdir(&base, Some("FROM x\nWORKDIR /srv\n"))
993 .expect("the Dockerfile declares one");
994
995 let purpose_built = BaseImage {
996 working_dir: "/app".to_string(),
997 ..BaseImage::al2023()
998 };
999 require_workdir(&purpose_built, None).expect("the base image declares one");
1000 }
1001
1002 /// A bare `WORKDIR` with no argument does not count as declaring one. It is the shape a
1003 /// truncated edit leaves behind, and treating it as a declaration would pass the guard
1004 /// on a Dockerfile that sets nothing.
1005 #[test]
1006 fn a_workdir_with_no_argument_does_not_count_as_a_declaration() {
1007 let error = require_workdir(&BaseImage::al2023(), Some("FROM x\nWORKDIR\n"))
1008 .expect_err("WORKDIR with no path declares nothing");
1009 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1010 }
1011
1012 /// The base image ARN, for the region it is requested in.
1013 ///
1014 /// `microvm-image:` with a **colon**, which is the spelling for a managed base *and* for
1015 /// a customer image alike — measured 2026-08-15 against real ARNs in us-east-1, and the
1016 /// only form the model's `TaggableResource` pattern admits. The docs above this function
1017 /// carry the measurement.
1018 ///
1019 /// **Falsification** — change the separator to `/` and both assertions go red. That was
1020 /// the state twelve fakes and the transport encoding test were in, which is why this
1021 /// test now asserts the separator explicitly rather than only the region.
1022 #[test]
1023 fn the_base_image_arn_names_the_request_region_with_a_colon_separator() {
1024 assert_eq!(
1025 BaseImage::al2023().arn(&crate::region::Region::UsEast1),
1026 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1027 );
1028 assert_eq!(
1029 BaseImage::al2023().arn(&crate::region::Region::ApNortheast1),
1030 "arn:aws:lambda:ap-northeast-1:aws:microvm-image:al2023-1"
1031 );
1032 assert!(
1033 !BaseImage::al2023()
1034 .arn(&crate::region::Region::UsEast1)
1035 .contains("microvm-image/"),
1036 "the slash form answers AccessDeniedException, not a validation error"
1037 );
1038 }
1039}