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