Skip to main content

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}