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    // The digest-pinned spelling of the same ref: `<docker_ref>@sha256:<64 hex>`. A digest
532    // names one specific manifest of the ref this check already accepts, so it is a stricter
533    // statement of agreement rather than a disagreement — a supply-chain-conscious caller
534    // pins exactly this way, and refusing it would force them to un-pin to pass. The tag
535    // must still be present and identical: `alpine@sha256:...` against a docker_ref of
536    // `alpine:3` is a different claim and stays refused.
537    if let Some(digest) = found
538        .strip_prefix(base.docker_ref.as_str())
539        .and_then(|rest| rest.strip_prefix("@sha256:"))
540        && digest.len() == 64
541        && digest.bytes().all(|b| b.is_ascii_hexdigit())
542    {
543        return Ok(());
544    }
545    Err(Error::invalid_arg(format!(
546        "the Dockerfile's FROM is {found:?} but base image {:?} pairs with {:?}. These must \
547         agree: baseImageArn and the FROM select the same base, and a mismatch builds against \
548         a base none of the measured platform behaviour applies to. Use default_dockerfile \
549         with this base, or pass a BaseImage whose docker_ref matches.",
550        base.name, base.docker_ref
551    )))
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    /// The archive holds exactly the two entries the build expects, named as the build
559    /// looks for them.
560    #[test]
561    fn the_artifact_holds_a_dockerfile_and_the_daemon() {
562        let bytes = build_artifact(b"\x7fELF fake daemon", "FROM scratch\n").expect("zips");
563        let mut archive =
564            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
565
566        let mut names: Vec<String> = (0..archive.len())
567            .map(|index| archive.by_index(index).expect("entry").name().to_string())
568            .collect();
569        names.sort();
570        assert_eq!(names, ["Dockerfile", "agentd"]);
571    }
572
573    /// The daemon entry carries mode 0755, and the Dockerfile entry does not have to.
574    ///
575    /// Read back out of the archive rather than asserted on the constant, because the
576    /// constant being right and the zip entry carrying it are two different facts — and it
577    /// is the second one that turns into a run-hook timeout.
578    ///
579    /// The permission bits are masked out of the returned mode: `zip` reports the whole
580    /// Unix mode, so the entry reads as `0o100755` — `S_IFREG | 0o755`. Comparing the
581    /// unmasked value against `0o755` fails while the archive is perfectly correct, which
582    /// is a test that would have sent someone to debug the writer.
583    #[test]
584    fn the_daemon_entry_carries_the_execute_bit() {
585        let bytes = build_artifact(b"binary", "FROM scratch\n").expect("zips");
586        let mut archive =
587            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
588        let mode = archive
589            .by_name("agentd")
590            .expect("the daemon entry")
591            .unix_mode()
592            .expect("a mode was recorded");
593        assert_eq!(
594            mode & 0o777,
595            AGENTD_MODE,
596            "a non-executable binary surfaces as a run-hook timeout, not a permission error \
597             (mode was {mode:o})"
598        );
599        assert!(
600            mode & 0o111 != 0,
601            "the execute bit is the whole point: {mode:o}"
602        );
603    }
604
605    /// The bytes round-trip. A zip that holds a truncated or re-encoded binary produces an
606    /// image whose CMD fails for a reason no message names.
607    #[test]
608    fn the_daemon_bytes_survive_the_round_trip() {
609        use std::io::Read as _;
610
611        // Deliberately includes a NUL and a high byte, which is what a real ELF has and
612        // what a text-mode write would mangle.
613        let binary: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
614        let bytes = build_artifact(&binary, "FROM scratch\n").expect("zips");
615        let mut archive =
616            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
617        let mut read = Vec::new();
618        archive
619            .by_name("agentd")
620            .expect("entry")
621            .read_to_end(&mut read)
622            .expect("reads");
623        assert_eq!(read, binary);
624    }
625
626    /// **AC-2-3, the byte-scan guard.** The agent token must not appear anywhere in the
627    /// artifact's raw bytes.
628    ///
629    /// A byte scan rather than an API review, because the leak this guards against is a
630    /// *value* turning up somewhere — in the Dockerfile as an `ENV`, in a baked config
631    /// file, in a stray argument — not a parameter being declared. Scanning the compressed
632    /// bytes and the decompressed entries both, since deflate would hide a literal from a
633    /// naive scan of the archive.
634    ///
635    /// **Falsification** — add the token to the Dockerfile (`ENV AGENT_TOKEN=…`, the
636    /// plausible mistake) and the decompressed scan fails.
637    #[test]
638    fn the_artifact_never_carries_the_agent_token() {
639        use std::io::Read as _;
640
641        let token = "s3cr3t-agent-token-do-not-bake-me";
642        let dockerfile = default_dockerfile(9000, Some("/opt/work"), &BaseImage::al2023());
643        assert!(
644            !dockerfile.contains(token),
645            "the default Dockerfile must not mention a token"
646        );
647
648        let bytes = build_artifact(b"binary", &dockerfile).expect("zips");
649        assert!(
650            !bytes
651                .windows(token.len())
652                .any(|window| window == token.as_bytes()),
653            "the token appears in the artifact's raw bytes"
654        );
655
656        let mut archive =
657            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
658        for index in 0..archive.len() {
659            let mut entry = archive.by_index(index).expect("entry");
660            let name = entry.name().to_string();
661            let mut content = Vec::new();
662            entry.read_to_end(&mut content).expect("reads");
663            assert!(
664                !content
665                    .windows(token.len())
666                    .any(|window| window == token.as_bytes()),
667                "the token appears inside {name} — the image snapshot is shared by every VM \
668                 launched from it, so a token here is a token shared with every VM"
669            );
670        }
671    }
672
673    /// The content hash is a pure function of the two inputs: equal inputs agree, either
674    /// input changing changes it, and the boundary between the two inputs is part of the
675    /// identity.
676    ///
677    /// The boundary case is the one worth spelling out: without length prefixes,
678    /// `("ab", "c")` and `("a", "bc")` hash the same concatenation — and a Dockerfile
679    /// edit could then collide with a binary edit, serving a stale image for changed
680    /// inputs, which is the exact hazard the hash exists to close.
681    #[test]
682    fn the_content_hash_follows_the_inputs_and_only_the_inputs() {
683        let hash = artifact_content_hash(b"binary-bytes", "FROM scratch\n");
684        assert_eq!(
685            hash,
686            artifact_content_hash(b"binary-bytes", "FROM scratch\n")
687        );
688        assert_eq!(hash.len(), 64, "sha256 as lowercase hex");
689        assert!(
690            hash.chars()
691                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
692        );
693
694        assert_ne!(
695            hash,
696            artifact_content_hash(b"other-bytes", "FROM scratch\n")
697        );
698        assert_ne!(
699            hash,
700            artifact_content_hash(b"binary-bytes", "FROM scratch\nRUN true\n")
701        );
702        assert_ne!(
703            artifact_content_hash(b"ab", "c"),
704            artifact_content_hash(b"a", "bc"),
705            "the input boundary is part of the identity"
706        );
707    }
708
709    /// The Dockerfile's `FROM` comes from the base image, so the two cannot disagree.
710    #[test]
711    fn the_default_dockerfile_derives_its_from_from_the_base_image() {
712        let base = BaseImage::al2023();
713        let dockerfile = default_dockerfile(9000, None, &base);
714        assert_eq!(
715            dockerfile_from_ref(&dockerfile),
716            Some(base.docker_ref.as_str())
717        );
718        assert!(dockerfile.contains(r#"CMD ["/agentd"]"#));
719        assert!(
720            dockerfile.contains("ENTRYPOINT []"),
721            "the trust boundary rests on this line"
722        );
723        assert!(dockerfile.contains("ENV AGENTD_PORT=9000"));
724    }
725
726    /// A workdir is written as both a `mkdir` and a `WORKDIR`, and is absent when none was
727    /// asked for. An empty string counts as none, since that is what an unset value looks
728    /// like coming from a CLI flag.
729    #[test]
730    fn a_workdir_is_created_and_set_or_absent_entirely() {
731        let base = BaseImage::al2023();
732        let with = default_dockerfile(9000, Some("/opt/baked-workdir"), &base);
733        assert!(with.contains("RUN mkdir -p /opt/baked-workdir"));
734        assert!(with.contains("WORKDIR /opt/baked-workdir"));
735
736        for none in [None, Some("")] {
737            let without = default_dockerfile(9000, none, &base);
738            assert!(!without.contains("WORKDIR"), "{without}");
739        }
740    }
741
742    /// `FROM` parsing tolerates the decoration a real Dockerfile carries: lowercase,
743    /// leading whitespace, a `--platform` flag, and an `AS` alias.
744    #[test]
745    fn the_from_parser_ignores_decoration_rather_than_validating_syntax() {
746        assert_eq!(dockerfile_from_ref("FROM alpine\n"), Some("alpine"));
747        assert_eq!(dockerfile_from_ref("  from alpine:3\n"), Some("alpine:3"));
748        assert_eq!(
749            dockerfile_from_ref("FROM --platform=linux/arm64 alpine AS build\n"),
750            Some("alpine")
751        );
752        assert_eq!(dockerfile_from_ref("RUN echo from nowhere\n"), None);
753        assert_eq!(dockerfile_from_ref(""), None);
754    }
755
756    /// A `FROM` that disagrees with the base image is refused, and the message names both
757    /// refs — the one found and the one expected — because "these must agree" without both
758    /// values leaves the caller to guess which to change.
759    #[test]
760    fn a_dockerfile_from_that_disagrees_with_the_base_is_refused() {
761        let base = BaseImage::al2023();
762        let error = require_matching_from(&base, "FROM ubuntu:24.04\nCOPY agentd /agentd\n")
763            .expect_err("ubuntu is not the managed base");
764        assert_eq!(error.kind(), ErrorKind::InvalidArg);
765        let message = error.to_string();
766        assert!(message.contains("ubuntu:24.04"), "{message}");
767        assert!(message.contains(&base.docker_ref), "{message}");
768        assert!(
769            message.contains("none of the measured platform behaviour applies"),
770            "{message}"
771        );
772
773        require_matching_from(&base, &default_dockerfile(9000, None, &base))
774            .expect("the derived Dockerfile agrees with its own base");
775    }
776
777    /// A digest-pinned `FROM` of the agreeing ref passes: it names one specific manifest of
778    /// the base this check already accepts, which is the spelling a supply-chain-conscious
779    /// caller uses. The boundary cases stay refused — a digest on a *different* ref is still
780    /// a disagreement, and a malformed digest is not a digest.
781    #[test]
782    fn a_digest_pinned_from_of_the_agreeing_ref_passes() {
783        let base = BaseImage::al2023();
784        let digest = "c439fb4994ea7ca529233d6256446d3f8b7b4efb58956073e015303a170011de";
785        let pinned = format!(
786            "FROM {}@sha256:{digest}\nCOPY agentd /agentd\n",
787            base.docker_ref
788        );
789        require_matching_from(&base, &pinned).expect("a digest pin of the same ref agrees");
790
791        require_matching_from(&base, &format!("FROM ubuntu:24.04@sha256:{digest}\n"))
792            .expect_err("a digest does not launder a different ref");
793        require_matching_from(&base, &format!("FROM {}@sha256:abc123\n", base.docker_ref))
794            .expect_err("a 6-character digest is not a digest");
795        require_matching_from(
796            &base,
797            &format!("FROM {}@sha256:{}\n", base.docker_ref, "g".repeat(64)),
798        )
799        .expect_err("64 non-hex characters are not a digest");
800    }
801
802    /// A Dockerfile with no `FROM` is not this check's business: the build will say so, and
803    /// refusing here would be a Dockerfile validator rather than an agreement check.
804    #[test]
805    fn a_dockerfile_with_no_from_is_left_to_the_build() {
806        require_matching_from(&BaseImage::al2023(), "COPY agentd /agentd\n")
807            .expect("no FROM is not a disagreement");
808    }
809
810    /// The measured case, reproduced from the Dockerfile that spent it: a hand-written
811    /// guest Dockerfile carrying `ENV AGENTD_PORT=8080` — the plausible port, and the wrong
812    /// one — against a client sending `hooks.port=9000`. The build succeeds, the daemon logs
813    /// `agentd listening`, and the image lands in `CREATE_FAILED` naming no port.
814    #[test]
815    fn a_dockerfile_port_that_disagrees_with_the_hook_port_is_refused() {
816        let error = require_matching_agentd_port(
817            crate::control::DEFAULT_AGENT_PORT,
818            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
819             COPY agentd /agentd\n\
820             ENV AGENTD_PORT=8080\n\
821             EXPOSE 8080\n\
822             CMD [\"/agentd\"]\n",
823        )
824        .expect_err("8080 is not the port the create call sends");
825        assert_eq!(error.kind(), ErrorKind::InvalidArg);
826        let message = error.to_string();
827        assert!(message.contains("8080"), "{message}");
828        assert!(message.contains("9000"), "{message}");
829        // The remedy is what a reader acts on, and the diagnostic's value is naming the
830        // symptom that points away from the cause.
831        assert!(message.contains("CREATE_FAILED"), "{message}");
832        assert!(message.contains("ready/validate"), "{message}");
833
834        require_matching_agentd_port(
835            crate::control::DEFAULT_AGENT_PORT,
836            &default_dockerfile(
837                crate::control::DEFAULT_AGENT_PORT,
838                Some("/work"),
839                &BaseImage::al2023(),
840            ),
841        )
842        .expect("the derived Dockerfile agrees with the port it was derived from");
843    }
844
845    /// Both `ENV` spellings set the variable, so a guard reading only `KEY=VALUE` would pass
846    /// the legacy form it could not parse. The last assignment wins, as it does at build
847    /// time.
848    #[test]
849    fn the_port_scan_reads_both_env_spellings_and_takes_the_last() {
850        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=9000\n"), Some(9000));
851        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT 8080\n"), Some(8080));
852        assert_eq!(dockerfile_agentd_port("env agentd_port=7000\n"), None);
853        assert_eq!(
854            dockerfile_agentd_port("ENV AGENTD_PORT=8080\nENV AGENTD_PORT=9000\n"),
855            Some(9000),
856        );
857        assert_eq!(dockerfile_agentd_port("ENV AGENTD_LOG=info\n"), None);
858        // Neither a missing variable nor an unparseable one is a disagreement: the daemon
859        // keeps its own default for both (`agentd/src/config.rs:118`).
860        assert_eq!(
861            dockerfile_agentd_port("FROM x\nCOPY agentd /agentd\n"),
862            None
863        );
864        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=nine\n"), None);
865        require_matching_agentd_port(
866            crate::control::DEFAULT_AGENT_PORT,
867            "FROM x\nCOPY agentd /agentd\n",
868        )
869        .expect("no variable agrees with the default, which is what the daemon will use");
870    }
871
872    /// The other half of the pair, and the harder one to diagnose: a Dockerfile that names
873    /// no port at all against a client that moved off the default. Silence is not neutral —
874    /// `Config::from_env` keeps `9000` for an unset variable — so the guest listens on 9000
875    /// while the hooks are dialled on the client's port, which is the measured failure with
876    /// nothing in the Dockerfile to point at.
877    #[test]
878    fn a_dockerfile_naming_no_port_is_refused_when_the_client_moved_off_the_default() {
879        let error = require_matching_agentd_port(
880            8080,
881            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
882             COPY agentd /agentd\n\
883             CMD [\"/agentd\"]\n",
884        )
885        .expect_err("a silent Dockerfile leaves the daemon on 9000, not on 8080");
886        assert_eq!(error.kind(), ErrorKind::InvalidArg);
887        let message = error.to_string();
888        assert!(message.contains("8080"), "{message}");
889        assert!(
890            message.contains(&crate::control::DEFAULT_AGENT_PORT.to_string()),
891            "the daemon's default is the value the reader has to learn: {message}"
892        );
893        assert!(message.contains("CREATE_FAILED"), "{message}");
894        assert!(message.contains("ready/validate"), "{message}");
895
896        // An unparseable value has the same consequence in the guest as no value, so it is
897        // refused for the same reason rather than passed as "not this guard's business".
898        require_matching_agentd_port(8080, "FROM x\nENV AGENTD_PORT=nine\n")
899            .expect_err("a value the daemon cannot parse leaves it on its own default");
900
901        // The default-port client is the common case and stays silent: the Dockerfile that
902        // says nothing and the client that changed nothing already agree.
903        require_matching_agentd_port(
904            crate::control::DEFAULT_AGENT_PORT,
905            "FROM x\nCOPY agentd /agentd\n",
906        )
907        .expect("silence agrees with the default");
908    }
909
910    /// The fourth pair of the `FROM`/`WORKDIR`/`AGENTD_PORT` shape, found by sweeping for
911    /// the others: a keepalive interval the client's silence tolerance is shorter than. The
912    /// resulting error prints the client's own timeout as though it were the keepalive, so
913    /// the number a reader would search for never appears.
914    #[test]
915    fn a_keepalive_at_or_over_the_client_idle_timeout_is_refused() {
916        let timeout = crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT;
917        assert_eq!(timeout.as_secs(), 60, "the guard's arithmetic assumes this");
918
919        let error =
920            require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=90\n")
921                .expect_err("90s of scheduled silence exceeds a 60s tolerance");
922        assert_eq!(error.kind(), ErrorKind::InvalidArg);
923        let message = error.to_string();
924        assert!(message.contains("90"), "{message}");
925        assert!(message.contains("60"), "{message}");
926
927        // Equality races rather than passing: the timeout fires on the same tick the
928        // keepalive is due.
929        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=60\n")
930            .expect_err("an interval equal to the tolerance is a race, not a margin");
931
932        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS 30\n")
933            .expect("30s leaves a margin, in the legacy spelling");
934        // The daemon's own default is 15s, so silence is safe for this pair.
935        require_keepalive_under_idle_timeout(timeout, "FROM x\nCOPY agentd /agentd\n")
936            .expect("an unset keepalive leaves the daemon at 15s");
937        require_keepalive_under_idle_timeout(
938            timeout,
939            &default_dockerfile(
940                crate::control::DEFAULT_AGENT_PORT,
941                Some("/work"),
942                &BaseImage::al2023(),
943            ),
944        )
945        .expect("the derived Dockerfile sets no keepalive");
946    }
947
948    /// **Issue #46, both refusable halves.** A Dockerfile with no `CMD` builds an image
949    /// that runs the base's default process; a non-empty `ENTRYPOINT` turns the `CMD` into
950    /// its arguments. Either way the daemon the artifact carries never starts, and the
951    /// failure surfaces as `CREATE_FAILED` or a run-hook timeout naming neither
952    /// instruction — so the message must name both the instruction and that symptom.
953    #[test]
954    fn a_dockerfile_that_never_runs_the_daemon_is_refused_naming_the_symptom() {
955        // No CMD at all: the base's default applies, which is not /agentd.
956        let error = require_daemon_cmd(
957            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
958             COPY agentd /agentd\n\
959             ENV AGENTD_PORT=9000\n",
960        )
961        .expect_err("no CMD means the daemon never starts");
962        assert_eq!(error.kind(), ErrorKind::InvalidArg);
963        let message = error.to_string();
964        assert!(message.contains("no CMD"), "{message}");
965        assert!(message.contains("run-hook timeout"), "{message}");
966        assert!(message.contains("CREATE_FAILED"), "{message}");
967
968        // A non-empty ENTRYPOINT swallows the CMD as its arguments.
969        let error = require_daemon_cmd(
970            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
971             COPY agentd /agentd\n\
972             ENTRYPOINT [\"/bin/sh\", \"-c\"]\n\
973             CMD [\"/agentd\"]\n",
974        )
975        .expect_err("a non-empty ENTRYPOINT makes CMD its arguments");
976        assert_eq!(error.kind(), ErrorKind::InvalidArg);
977        let message = error.to_string();
978        assert!(message.contains("ENTRYPOINT"), "{message}");
979        assert!(message.contains("run-hook timeout"), "{message}");
980
981        // An empty CMD is the same absence in different spelling.
982        require_daemon_cmd("FROM x\nCMD []\n").expect_err("CMD [] runs nothing");
983    }
984
985    /// The shapes that must pass: the deployment invariant itself, in exec and spaced
986    /// spellings, and the invariant's `ENTRYPOINT []` with any interior whitespace. The
987    /// derived Dockerfile passes its own guard, which is what keeps the default path
988    /// unaffected.
989    #[test]
990    fn the_deployment_invariant_passes_the_daemon_cmd_guard() {
991        require_daemon_cmd(&default_dockerfile(
992            9000,
993            Some("/work"),
994            &BaseImage::al2023(),
995        ))
996        .expect("the derived Dockerfile is the invariant");
997        require_daemon_cmd("FROM x\nENTRYPOINT []\nCMD [\"/agentd\"]\n").expect("the invariant");
998        require_daemon_cmd("FROM x\nENTRYPOINT [ ]\nCMD [\"/agentd\"]\n")
999            .expect("interior whitespace is still the empty exec form");
1000        require_daemon_cmd("FROM x\ncmd [\"/agentd\"]\n").expect("case-insensitive, no ENTRYPOINT");
1001        // Last instruction wins, as at build time: a later empty ENTRYPOINT un-swallows.
1002        require_daemon_cmd("FROM x\nENTRYPOINT [\"/bin/sh\"]\nENTRYPOINT []\nCMD [\"/agentd\"]\n")
1003            .expect("the last ENTRYPOINT is the one the build uses");
1004        // And the reverse ordering is refused for the same reason.
1005        require_daemon_cmd("FROM x\nENTRYPOINT []\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n")
1006            .expect_err("the last ENTRYPOINT is non-empty");
1007    }
1008
1009    /// Workdir inheritance is refused when neither the base nor the Dockerfile declares
1010    /// one, and the message says where the symptom would have appeared — in the guest, a
1011    /// build cycle later.
1012    #[test]
1013    fn inheriting_a_workdir_nothing_declares_is_refused() {
1014        let base = BaseImage::al2023();
1015        assert!(base.working_dir.is_empty(), "the measured case");
1016
1017        let error = require_workdir(&base, None).expect_err("nothing declares a workdir");
1018        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1019        let message = error.to_string();
1020        assert!(message.contains("no WorkingDir"), "{message}");
1021        assert!(message.contains("nothing to inherit"), "{message}");
1022        assert!(message.contains("docs/PLATFORM.md"), "{message}");
1023    }
1024
1025    /// It is accepted when *either* side declares one — the Dockerfile's `WORKDIR` or the
1026    /// base image's own `working_dir`. Both directions, because a check that only read the
1027    /// Dockerfile would refuse a caller on a purpose-built image that declares one.
1028    #[test]
1029    fn either_the_dockerfile_or_the_base_may_supply_the_workdir() {
1030        let base = BaseImage::al2023();
1031        require_workdir(&base, Some("FROM x\nWORKDIR /srv\n"))
1032            .expect("the Dockerfile declares one");
1033
1034        let purpose_built = BaseImage {
1035            working_dir: "/app".to_string(),
1036            ..BaseImage::al2023()
1037        };
1038        require_workdir(&purpose_built, None).expect("the base image declares one");
1039    }
1040
1041    /// A bare `WORKDIR` with no argument does not count as declaring one. It is the shape a
1042    /// truncated edit leaves behind, and treating it as a declaration would pass the guard
1043    /// on a Dockerfile that sets nothing.
1044    #[test]
1045    fn a_workdir_with_no_argument_does_not_count_as_a_declaration() {
1046        let error = require_workdir(&BaseImage::al2023(), Some("FROM x\nWORKDIR\n"))
1047            .expect_err("WORKDIR with no path declares nothing");
1048        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1049    }
1050
1051    /// The base image ARN, for the region it is requested in.
1052    ///
1053    /// `microvm-image:` with a **colon**, which is the spelling for a managed base *and* for
1054    /// a customer image alike — measured 2026-08-15 against real ARNs in us-east-1, and the
1055    /// only form the model's `TaggableResource` pattern admits. The docs above this function
1056    /// carry the measurement.
1057    ///
1058    /// **Falsification** — change the separator to `/` and both assertions go red. That was
1059    /// the state twelve fakes and the transport encoding test were in, which is why this
1060    /// test now asserts the separator explicitly rather than only the region.
1061    #[test]
1062    fn the_base_image_arn_names_the_request_region_with_a_colon_separator() {
1063        assert_eq!(
1064            BaseImage::al2023().arn(&crate::region::Region::UsEast1),
1065            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1066        );
1067        assert_eq!(
1068            BaseImage::al2023().arn(&crate::region::Region::ApNortheast1),
1069            "arn:aws:lambda:ap-northeast-1:aws:microvm-image:al2023-1"
1070        );
1071        assert!(
1072            !BaseImage::al2023()
1073                .arn(&crate::region::Region::UsEast1)
1074                .contains("microvm-image/"),
1075            "the slash form answers AccessDeniedException, not a validation error"
1076        );
1077    }
1078}