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