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, a Dockerfile, and — when the caller
3//! passes [`ProjectFiles`] — the project's dependency files.
4//!
5//! What `CreateMicrovmImage`'s `codeArtifact.uri` points at (`sandbox.py:523`
6//! `build_artifact`). Two entries always, both carrying a measured constraint, plus an
7//! ecosystem-named manifest/lockfile pair when the image bakes an environment layer (#74).
8//!
9//! # The execute bit has to be in the zip entry
10//!
11//! A build that copies a non-executable binary produces an image whose `CMD` fails, and
12//! the failure surfaces as a **run-hook timeout** — which says nothing about permissions,
13//! and sends the reader to look at the daemon's startup path instead of at the archive.
14//! So the `agentd` entry sets mode `0o755` explicitly rather than inheriting whatever the
15//! host file had.
16//!
17//! # The agent token is never in here (TRAP-5's other half)
18//!
19//! The artifact becomes a **shared image snapshot**: every MicroVM launched from the image
20//! sees the same bytes. A per-VM secret in there is a per-VM secret shared with every VM.
21//! So the token travels through `runHookPayload` at launch instead, and this function has
22//! no parameter that could carry one — see [`crate::control::microvm`]. The test at the
23//! bottom of this file scans the produced zip's raw bytes for a token value to prove the
24//! path stays closed, which is a byte scan rather than an API review because the leak
25//! would be a *value* appearing somewhere, not a parameter being declared.
26
27use std::io::Write as _;
28use std::time::Duration;
29
30use crate::error::{Error, ErrorKind};
31
32/// The Dockerfile entry's name, which the build looks for by convention.
33const DOCKERFILE_ENTRY: &str = "Dockerfile";
34
35/// The daemon entry's name, matching the `CMD ["/agentd"]` the Dockerfile sets.
36const AGENTD_ENTRY: &str = "agentd";
37
38/// The mode the daemon entry carries.
39///
40/// See the module docs: a non-executable binary here becomes a run-hook timeout later.
41const AGENTD_MODE: u32 = 0o755;
42
43/// An ecosystem whose dependency files may enter the build artifact (#74).
44///
45/// The variant fixes **both entry names**. That is the TRAP-5 half of the design: the
46/// artifact is a shared image snapshot, so what may enter it is an allowlist of
47/// dependency-defining files, not a caller-named list — there is no field anywhere in this
48/// module a caller could put `.env` or a credentials file's name into. The byte-scan test
49/// at the bottom of this file covers the entries this type admits.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum Ecosystem {
52    /// Python under uv: `pyproject.toml` + `uv.lock`.
53    Uv,
54    /// Node under npm: `package.json` + `package-lock.json`.
55    Npm,
56    /// Rust under cargo: `Cargo.toml` + `Cargo.lock`.
57    Cargo,
58}
59
60impl Ecosystem {
61    /// Every ecosystem, in detection order.
62    pub const ALL: [Self; 3] = [Self::Uv, Self::Npm, Self::Cargo];
63
64    /// The manifest's file name, which is also its zip entry name.
65    pub fn manifest_name(self) -> &'static str {
66        match self {
67            Self::Uv => "pyproject.toml",
68            Self::Npm => "package.json",
69            Self::Cargo => "Cargo.toml",
70        }
71    }
72
73    /// The lockfile's file name, which is also its zip entry name — and the file the
74    /// environment layer is keyed on (#74).
75    pub fn lockfile_name(self) -> &'static str {
76        match self {
77            Self::Uv => "uv.lock",
78            Self::Npm => "package-lock.json",
79            Self::Cargo => "Cargo.lock",
80        }
81    }
82
83    /// The whole `RUN` line that installs the environment layer: toolchain bootstrap on
84    /// the managed al2023 base, then the lockfile-faithful install.
85    ///
86    /// # The install commands are the lockfile-faithful spellings
87    ///
88    /// `uv sync --locked`, `npm ci`, and `cargo fetch --locked` each **refuse** a lockfile
89    /// that disagrees with its manifest rather than quietly re-resolving — which is what
90    /// keeps the baked layer the thing the content hash says it is. `uv sync` without
91    /// `--locked`, `npm install`, or a bare `cargo fetch` would install *something* and
92    /// the hash would then name an environment the build did not produce.
93    ///
94    /// # The bootstrap packages are measured, the full build is not yet
95    ///
96    /// The managed base is al2023-minimal and ships no toolchain, so each line installs
97    /// its own. Package names and the binaries they land were verified against the
98    /// Amazon Linux 2023 repos (2026-08-31): `python3.12-pip` puts `pip3.12` on PATH,
99    /// `nodejs22-npm` puts `npm` and `node` on PATH, `cargo` puts `cargo` on PATH.
100    /// `install_weak_deps=0` keeps the layer minimal. A full in-guest build of each line
101    /// is the live-conformance scenario #74's acceptance defers to — see the issue.
102    ///
103    /// Cargo is the one that needs a stub: `cargo fetch` refuses a package with no
104    /// targets, so the line creates an empty `src/main.rs` first. The working tree synced
105    /// at launch lands over it.
106    pub fn install_run_line(self) -> &'static str {
107        match self {
108            Self::Uv => {
109                "RUN dnf -y --setopt=install_weak_deps=0 install python3.12 python3.12-pip \
110                 && dnf clean all && pip3.12 install --no-cache-dir uv && uv sync --locked"
111            }
112            Self::Npm => {
113                "RUN dnf -y --setopt=install_weak_deps=0 install nodejs22-npm \
114                 && dnf clean all && npm ci"
115            }
116            Self::Cargo => {
117                "RUN dnf -y --setopt=install_weak_deps=0 install cargo \
118                 && dnf clean all && mkdir -p src && touch src/main.rs \
119                 && cargo fetch --locked"
120            }
121        }
122    }
123}
124
125/// A project's dependency files: the lockfile plus the manifest that owns it.
126///
127/// Bytes rather than paths, for the reason [`build_artifact`]'s `binary` is bytes: the
128/// caller owns the read, and this module keeps no filesystem behaviour to stub in a test.
129/// The entry names come from [`Ecosystem`], never from the caller — see the type's docs
130/// for why that is a TRAP-5 property rather than a convenience.
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ProjectFiles {
133    /// Which ecosystem's pair this is, fixing both entry names.
134    pub ecosystem: Ecosystem,
135    /// The manifest's bytes (`pyproject.toml`, `package.json`, `Cargo.toml`).
136    pub manifest: Vec<u8>,
137    /// The lockfile's bytes (`uv.lock`, `package-lock.json`, `Cargo.lock`).
138    pub lockfile: Vec<u8>,
139}
140
141/// Zips `binary` with `dockerfile` — and, when given, the project's dependency files —
142/// into the bytes `codeArtifact.uri` will point at.
143///
144/// `binary` is the daemon's bytes rather than a path, so the caller owns the read and this
145/// function has no filesystem behaviour to stub in a test. `project` adds exactly two more
146/// entries, named by its [`Ecosystem`], at the archive root beside the Dockerfile — which
147/// is the build context root, so a `COPY pyproject.toml uv.lock …` in the Dockerfile finds
148/// them.
149pub fn build_artifact(
150    binary: &[u8],
151    dockerfile: &str,
152    project: Option<&ProjectFiles>,
153) -> Result<Vec<u8>, Error> {
154    use zip::write::{SimpleFileOptions, ZipWriter};
155
156    let mut writer = ZipWriter::new(std::io::Cursor::new(Vec::new()));
157    let deflated =
158        SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
159
160    let mut write_entry = |name: &str, bytes: &[u8], options: SimpleFileOptions| {
161        writer
162            .start_file(name, options)
163            .and_then(|()| writer.write_all(bytes).map_err(Into::into))
164            .map_err(|error| {
165                Error::new(
166                    ErrorKind::Unexpected,
167                    format!("could not add {name} to the build artifact: {error}"),
168                )
169            })
170    };
171
172    write_entry(DOCKERFILE_ENTRY, dockerfile.as_bytes(), deflated)?;
173    write_entry(AGENTD_ENTRY, binary, deflated.unix_permissions(AGENTD_MODE))?;
174    if let Some(project) = project {
175        write_entry(
176            project.ecosystem.manifest_name(),
177            &project.manifest,
178            deflated,
179        )?;
180        write_entry(
181            project.ecosystem.lockfile_name(),
182            &project.lockfile,
183            deflated,
184        )?;
185    }
186
187    let bytes = writer
188        .finish()
189        .map_err(|error| {
190            Error::new(
191                ErrorKind::Unexpected,
192                format!("could not finish the build artifact: {error}"),
193            )
194        })?
195        .into_inner();
196    Ok(bytes)
197}
198
199/// The sha256 of the artifact's inputs, as lowercase hex.
200///
201/// # What is hashed, and why not the zip
202///
203/// The **inputs** — the daemon binary's bytes, the Dockerfile text, and the project
204/// files when the artifact carries them — rather than the bytes [`build_artifact`]
205/// produces. The zip is a container: its byte identity depends on the `zip` crate's
206/// version, its compression level, and its header defaults, so an upgraded dependency
207/// would silently change every image name and orphan every reuse. The inputs are what a
208/// build actually consumes, and two builds with equal inputs produce interchangeable
209/// images — which is the property content-addressed reuse rests on.
210///
211/// Each input is length-prefixed before hashing, so `(binary="ab", dockerfile="c")` and
212/// `(binary="a", dockerfile="bc")` are different hashes rather than one concatenation.
213/// A project file contributes its entry **name and bytes**, both length-prefixed, so the
214/// same bytes under a different ecosystem — `Cargo.lock` and `package-lock.json` holding
215/// identical text — are different identities too.
216///
217/// # `None` is the historical digest, deliberately
218///
219/// With no project files the digest stream is byte-identical to what this function
220/// produced before #74, so every projectless image name in every account survives the
221/// change — the alternative orphans exactly the reuse this hash exists to serve. The
222/// pinned vector in the tests is what holds that fixed. This is #74's key: two projects
223/// with identical lockfiles share an image, and a lockfile edit is a new name and a
224/// fresh build.
225pub fn artifact_content_hash(
226    binary: &[u8],
227    dockerfile: &str,
228    project: Option<&ProjectFiles>,
229) -> String {
230    use sha2::{Digest as _, Sha256};
231
232    let mut hasher = Sha256::new();
233    hasher.update((binary.len() as u64).to_be_bytes());
234    hasher.update(binary);
235    hasher.update((dockerfile.len() as u64).to_be_bytes());
236    hasher.update(dockerfile.as_bytes());
237    if let Some(project) = project {
238        for (name, bytes) in [
239            (project.ecosystem.manifest_name(), &project.manifest),
240            (project.ecosystem.lockfile_name(), &project.lockfile),
241        ] {
242            hasher.update((name.len() as u64).to_be_bytes());
243            hasher.update(name.as_bytes());
244            hasher.update((bytes.len() as u64).to_be_bytes());
245            hasher.update(bytes);
246        }
247    }
248    const_hex::encode(hasher.finalize())
249}
250
251/// A base image: the platform ARN, the Dockerfile `FROM` that pairs with it, and whether
252/// it declares a `WORKDIR`.
253///
254/// All three together because the first two **must** agree and used to be able to
255/// disagree: the Python client's `DEFAULT_BASE_IMAGE` named the managed base for
256/// `baseImageArn` while `default_dockerfile` hardcoded an unrelated registry literal in
257/// its `FROM`, so changing either left the other pointing somewhere else
258/// (`sandbox.py:410-444`). Pairing them means a caller selects one thing and both fields
259/// follow.
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct BaseImage {
262    /// Goes into `baseImageArn` — the platform's managed base, not a registry ref.
263    pub name: String,
264    /// Goes into the Dockerfile `FROM` — the registry ref measured alongside `name`.
265    pub docker_ref: String,
266    /// What `docker inspect` reports for `WorkingDir`. Empty means it declares none.
267    ///
268    /// A field rather than a lookup because a caller with a purpose-built image is the
269    /// only one who can say what their image declares, and this client cannot read it
270    /// without pulling the manifest.
271    pub working_dir: String,
272}
273
274impl BaseImage {
275    /// The managed base every `docs/PLATFORM.md` measurement from 2026-08-06 onward used,
276    /// paired with the registry ref those same builds used as `FROM`.
277    ///
278    /// `working_dir` is empty, and that is measured rather than assumed:
279    /// `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave `WorkingDir`
280    /// empty (2026-08-05), which is what makes [`require_workdir`] necessary.
281    pub fn al2023() -> Self {
282        Self {
283            name: "al2023-1".to_string(),
284            docker_ref: "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal".to_string(),
285            working_dir: String::new(),
286        }
287    }
288
289    /// The `baseImageArn` for this base in `region`.
290    ///
291    /// # `microvm-image:<name>`, with a colon, and it is not just the managed base
292    ///
293    /// This was the only place in the repo building the colon form; the fakes and the
294    /// encoding test all used `microvm-image/<name>`, so the repo held two beliefs about the
295    /// shape of the identifier every image call takes. The model's `TaggableResource` pattern
296    /// admits only `(capacity-provider|network-connector|microvm-image):[a-zA-Z0-9-_]+`, and
297    /// a live read settled it: `ListMicrovmImages` in us-east-1 returns
298    /// `arn:aws:lambda:us-east-1:<account>:microvm-image:coding-agents-on-bedrock` for a
299    /// customer image, and `GetMicrovmImage` accepts exactly that (measured 2026-08-15).
300    ///
301    /// So the colon is right for **both** the managed base and a customer image; there is no
302    /// two-form rule, and this comment used to imply there was. The slash form fails as
303    /// `AccessDeniedException` rather than as a validation error, because IAM evaluates the
304    /// malformed ARN as a resource with no matching policy — a permissions message for a
305    /// resource that exists.
306    pub fn arn(&self, region: &crate::region::Region) -> String {
307        format!(
308            "arn:aws:lambda:{}:aws:microvm-image:{}",
309            region.as_str(),
310            self.name
311        )
312    }
313}
314
315impl Default for BaseImage {
316    fn default() -> Self {
317        Self::al2023()
318    }
319}
320
321/// Where the environment layer lives when the caller named no workdir of their own.
322///
323/// A constant rather than `/` because the install commands resolve everything against the
324/// current directory — `uv sync` writes `.venv` beside the manifest, `npm ci` writes
325/// `node_modules` — and the working tree synced at launch has to land in the same place
326/// for the layer to be *its* environment.
327pub const DEFAULT_PROJECT_WORKDIR: &str = "/project";
328
329/// A Dockerfile that makes the daemon the container `CMD`.
330///
331/// `ENTRYPOINT []` plus `CMD ["/agentd"]` is the deployment invariant the trust boundary
332/// rests on: it is what guarantees no task workload runs before the platform's run hook
333/// lands. It is also what makes an omitted `cwd` inherit the image `WORKDIR`, since the
334/// daemon's own cwd is the image's.
335///
336/// The `FROM` is derived from `base` rather than written here, so it cannot disagree with
337/// the `baseImageArn` the create call sends.
338///
339/// `project` bakes the environment layer (#74): the manifest+lockfile pair is copied into
340/// the working directory — `workdir` when given, [`DEFAULT_PROJECT_WORKDIR`] otherwise —
341/// and the ecosystem's [`Ecosystem::install_run_line`] installs from the lockfile. Without
342/// it the derived Dockerfile installs nothing, which was the measured state this closes:
343/// every launch then pays dependency installation inside the guest, the 31–48% of launch
344/// time `docs/STRATEGY.md` attributes to environment init.
345///
346/// The invariant is *unenforced*: a base image that starts its own background process
347/// before bootstrap breaks it, and enforcing that belongs to whoever builds the image
348/// (`docs/PROTOCOL.md`, "Trust boundary").
349pub fn default_dockerfile(
350    port: u16,
351    workdir: Option<&str>,
352    base: &BaseImage,
353    project: Option<Ecosystem>,
354) -> String {
355    let mut lines = vec![
356        format!("FROM {}", base.docker_ref),
357        "COPY agentd /agentd".to_string(),
358        "RUN chmod 0755 /agentd".to_string(),
359    ];
360    let workdir = match (workdir.filter(|dir| !dir.is_empty()), project) {
361        (Some(dir), _) => Some(dir),
362        // The layer needs a directory to live in even when the caller named none.
363        (None, Some(_)) => Some(DEFAULT_PROJECT_WORKDIR),
364        (None, None) => None,
365    };
366    if let Some(workdir) = workdir {
367        lines.push(format!("RUN mkdir -p {workdir}"));
368        lines.push(format!("WORKDIR {workdir}"));
369    }
370    if let Some(ecosystem) = project {
371        // Relative destination, resolving against the WORKDIR set above — the same
372        // directory the install command runs in.
373        lines.push(format!(
374            "COPY {} {} ./",
375            ecosystem.manifest_name(),
376            ecosystem.lockfile_name(),
377        ));
378        lines.push(ecosystem.install_run_line().to_string());
379    }
380    lines.extend([
381        format!("ENV AGENTD_PORT={port}"),
382        "ENV AGENTD_LOG=info".to_string(),
383        format!("EXPOSE {port}"),
384        "ENTRYPOINT []".to_string(),
385        r#"CMD ["/agentd"]"#.to_string(),
386        String::new(),
387    ]);
388    lines.join("\n")
389}
390
391/// The image ref in a Dockerfile's first `FROM`, or `None` when it has none.
392///
393/// Deliberately loose on whitespace and case, and it ignores `--platform=` and `AS name`
394/// decoration, because the check exists to catch a base that disagrees rather than to
395/// validate Dockerfile syntax.
396///
397/// Split-and-scan rather than a regex: the pattern is "first token after FROM on a line
398/// whose first word is FROM", which `split_whitespace` states more directly than a
399/// pattern string would. (`regex-lite` is available if this ever grows.)
400pub fn dockerfile_from_ref(dockerfile: &str) -> Option<&str> {
401    for line in dockerfile.lines() {
402        let mut words = line.split_whitespace();
403        let Some(first) = words.next() else { continue };
404        if !first.eq_ignore_ascii_case("FROM") {
405            continue;
406        }
407        // Skip flag decoration such as `--platform=linux/arm64`.
408        return words.find(|word| !word.starts_with("--"));
409    }
410    None
411}
412
413/// Rejects working-directory inheritance when nothing declares one.
414///
415/// Measured 2026-08-05: `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave
416/// `WorkingDir` empty, so "inherit the image WORKDIR" inherits `/` and every relative path
417/// in the caller's commands resolves somewhere they did not mean.
418///
419/// Rejected rather than warned because the symptom appears in the **guest, one build cycle
420/// later**, as commands running in the wrong directory rather than as anything about
421/// `WORKDIR`.
422pub fn require_workdir(base: &BaseImage, dockerfile: Option<&str>) -> Result<(), Error> {
423    if !base.working_dir.is_empty() {
424        return Ok(());
425    }
426    let declares_workdir = dockerfile.is_some_and(|text| {
427        text.lines().any(|line| {
428            let mut words = line.split_whitespace();
429            words
430                .next()
431                .is_some_and(|first| first.eq_ignore_ascii_case("WORKDIR"))
432                && words.next().is_some()
433        })
434    });
435    if declares_workdir {
436        return Ok(());
437    }
438    Err(Error::invalid_arg(format!(
439        "inherit_workdir was requested but base image {:?} declares no WorkingDir and the \
440         Dockerfile sets none. Most public ARM64 base images leave it empty \
441         (docs/PLATFORM.md, 'Most public ARM64 base images have no WORKDIR'), so there is \
442         nothing to inherit and every relative path would resolve against `/`. Pass a workdir \
443         to default_dockerfile, or set WORKDIR in your own Dockerfile.",
444        base.name
445    )))
446}
447
448/// The port in a Dockerfile's last `ENV AGENTD_PORT=`, or `None` when it sets none.
449///
450/// Last rather than first: a later `ENV` of the same name wins at build time, so the last
451/// one is what the daemon reads. Accepts `ENV AGENTD_PORT=9000` and the legacy
452/// `ENV AGENTD_PORT 9000` spelling, since both set the variable and a guard that only
453/// understands one form passes the file it cannot parse.
454///
455/// Split-and-scan for the same reason [`dockerfile_from_ref`] is: `split_whitespace`
456/// states the pattern more directly than a regex string would here.
457pub fn dockerfile_agentd_port(dockerfile: &str) -> Option<u16> {
458    let mut found = None;
459    for line in dockerfile.lines() {
460        let mut words = line.split_whitespace();
461        let Some(first) = words.next() else { continue };
462        if !first.eq_ignore_ascii_case("ENV") {
463            continue;
464        }
465        let Some(assignment) = words.next() else {
466            continue;
467        };
468        let value = match assignment.split_once('=') {
469            Some(("AGENTD_PORT", value)) => value,
470            Some(_) => continue,
471            // `ENV AGENTD_PORT 9000`: the value is the next word.
472            None if assignment == "AGENTD_PORT" => match words.next() {
473                Some(value) => value,
474                None => continue,
475            },
476            None => continue,
477        };
478        // An unparseable value is not this guard's business: the daemon keeps its default
479        // for one (`agentd/src/config.rs:118`), so there is no disagreement to report.
480        if let Ok(port) = value.parse() {
481            found = Some(port);
482        }
483    }
484    found
485}
486
487/// Rejects a Dockerfile whose `AGENTD_PORT` disagrees with the port the create call sends
488/// as `hooks.port`.
489///
490/// The two are set independently — `hooks.port` comes from `ControlPlane::port`, the
491/// variable from the caller's own Dockerfile — and the platform calls its build-time
492/// `ready`/`validate` hooks on the port in the create call. A guest listening elsewhere
493/// answers nothing, so the build fails.
494///
495/// Rejected rather than warned because the failure points away from its cause: the docker
496/// build succeeds, the log group holds a clean build *and* the daemon's own "agentd
497/// listening" line, and the image still lands in `CREATE_FAILED`. Neither
498/// `GetMicrovmImage` nor the build log names the port — only
499/// `GetMicrovmImageVersion`'s `hooks.port` does, compared by hand against the Dockerfile.
500///
501/// A Dockerfile that sets no `AGENTD_PORT` is checked against
502/// [`DEFAULT_AGENT_PORT`](crate::control::DEFAULT_AGENT_PORT) rather than passed, because
503/// silence is not neutral: `Config::from_env` keeps its own default for an unset variable
504/// (`agentd/src/config.rs:118`, default `agentd/src/config.rs:84`), so the guest listens on
505/// 9000 whether or not the Dockerfile mentions a port. Against a client that changed its
506/// port, the absent variable produces exactly the measured failure — and produces it for a
507/// caller who never typed a port anywhere, which is the harder version to diagnose.
508pub fn require_matching_agentd_port(port: u16, dockerfile: &str) -> Result<(), Error> {
509    // An unparseable value lands here too, and belongs here: the daemon warns and keeps the
510    // same default (`agentd/src/config.rs:174-179`), so both spellings of "the Dockerfile
511    // named no usable port" have the same consequence in the guest.
512    let Some(found) = dockerfile_agentd_port(dockerfile) else {
513        if port == super::DEFAULT_AGENT_PORT {
514            return Ok(());
515        }
516        return Err(Error::invalid_arg(format!(
517            "this client sends hooks.port={port} but the Dockerfile sets no usable \
518             AGENTD_PORT, so the daemon will listen on its own default of {default}. These \
519             must agree: the platform calls the build-time ready/validate hooks on the port \
520             in the create call, and a daemon listening on {default} answers none of them — \
521             the docker build succeeds, the daemon logs that it is listening, and the image \
522             still fails with CREATE_FAILED naming no port. Add ENV AGENTD_PORT={port} to \
523             the Dockerfile, or build it with default_dockerfile, which derives the value \
524             from the same port.",
525            default = super::DEFAULT_AGENT_PORT,
526        )));
527    };
528    if found == port {
529        return Ok(());
530    }
531    Err(Error::invalid_arg(format!(
532        "the Dockerfile sets ENV AGENTD_PORT={found} but this client sends hooks.port={port}. \
533         These must agree: the platform calls the build-time ready/validate hooks on the port \
534         in the create call, and a daemon listening on {found} answers none of them — the \
535         docker build succeeds, the daemon logs that it is listening, and the image still \
536         fails with CREATE_FAILED naming no port. Set ENV AGENTD_PORT={port} in the \
537         Dockerfile, or build it with default_dockerfile, which derives the value from the \
538         same port."
539    )))
540}
541
542/// The seconds in a Dockerfile's last `ENV AGENTD_SSE_KEEPALIVE_SECS=`, or `None` when it
543/// sets none.
544///
545/// Same shape and same reasoning as [`dockerfile_agentd_port`]: last assignment wins, both
546/// `ENV` spellings, and an unparseable value reads as absent because the daemon warns and
547/// keeps its default for one (`agentd/src/config.rs:174-179`).
548pub fn dockerfile_env_u64(dockerfile: &str, key: &str) -> Option<u64> {
549    let mut found = None;
550    for line in dockerfile.lines() {
551        let mut words = line.split_whitespace();
552        let Some(first) = words.next() else { continue };
553        if !first.eq_ignore_ascii_case("ENV") {
554            continue;
555        }
556        let Some(assignment) = words.next() else {
557            continue;
558        };
559        let value = match assignment.split_once('=') {
560            Some((name, value)) if name == key => value,
561            Some(_) => continue,
562            None if assignment == key => match words.next() {
563                Some(value) => value,
564                None => continue,
565            },
566            None => continue,
567        };
568        if let Ok(parsed) = value.parse() {
569            found = Some(parsed);
570        }
571    }
572    found
573}
574
575/// Rejects a Dockerfile whose `AGENTD_SSE_KEEPALIVE_SECS` is not shorter than the client's
576/// stream idle timeout.
577///
578/// The fourth pair of this shape, found by sweeping for the other three. The daemon's SSE
579/// keepalive interval and the client's tolerance for silence are set in different
580/// repositories of truth — the interval in the caller's Dockerfile
581/// (`agentd/src/config.rs:139`, default 15s at `:95`), the tolerance in
582/// [`DEFAULT_STREAM_IDLE_TIMEOUT`](crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT),
583/// which is 60s *because* it is four times that 15. Raise the interval past the tolerance
584/// and every attached stream treats a healthy connection as dead, reconnects
585/// `max_reconnects` times, and fails.
586///
587/// The failure names both numbers and misattributes one of them: the client reports that the
588/// stream "went silent for 60s, longer than the keepalive interval"
589/// (`microvms-core/src/session/http.rs:383-387`), where 60 is its own timeout and the
590/// keepalive interval is the number it does not know. So a reader is told the keepalive was
591/// exceeded by a message that prints the wrong value for it, one build cycle after the
592/// Dockerfile that caused it.
593///
594/// Equality is refused, not just excess: an interval exactly equal to the timeout races.
595pub fn require_keepalive_under_idle_timeout(
596    idle_timeout: Duration,
597    dockerfile: &str,
598) -> Result<(), Error> {
599    let Some(secs) = dockerfile_env_u64(dockerfile, "AGENTD_SSE_KEEPALIVE_SECS") else {
600        // Absent is safe here, unlike the port: the daemon's default of 15s is already
601        // under every idle timeout this client will use, and a client that shortens its
602        // timeout below 15s is not something a Dockerfile can be blamed for.
603        return Ok(());
604    };
605    if secs < idle_timeout.as_secs() {
606        return Ok(());
607    }
608    Err(Error::invalid_arg(format!(
609        "the Dockerfile sets ENV AGENTD_SSE_KEEPALIVE_SECS={secs} but this client treats a \
610         stream as dead after {timeout}s of silence. The keepalive must be shorter: the \
611         daemon sends nothing between events except that keepalive, so an interval of \
612         {secs}s makes a healthy stream look dead, and every attach reconnects until it \
613         gives up. The error it raises then reports the client's own {timeout}s as though it \
614         were the keepalive interval, so it names neither the Dockerfile nor {secs}. Leave \
615         the variable unset for the daemon's 15s default, or keep it under {timeout}.",
616        timeout = idle_timeout.as_secs(),
617    )))
618}
619
620/// The value of a Dockerfile's last `ENTRYPOINT`, or `None` when it sets none.
621///
622/// Last rather than first for the reason [`dockerfile_agentd_port`] gives: a later
623/// instruction of the same name wins at build time, so the last one is what the container
624/// runs. The value is the raw rest of the line — this scan finds a disagreement, it does
625/// not parse Dockerfile syntax.
626pub fn dockerfile_entrypoint(dockerfile: &str) -> Option<&str> {
627    last_instruction_value(dockerfile, "ENTRYPOINT")
628}
629
630/// The value of a Dockerfile's last `CMD`, or `None` when it sets none.
631pub fn dockerfile_cmd(dockerfile: &str) -> Option<&str> {
632    last_instruction_value(dockerfile, "CMD")
633}
634
635/// The rest of the last line whose first word is `keyword`, trimmed, or `None`.
636fn last_instruction_value<'a>(dockerfile: &'a str, keyword: &str) -> Option<&'a str> {
637    let mut found = None;
638    for line in dockerfile.lines() {
639        let trimmed = line.trim_start();
640        let Some(first) = trimmed.split_whitespace().next() else {
641            continue;
642        };
643        if first.eq_ignore_ascii_case(keyword) {
644            found = Some(trimmed[first.len()..].trim());
645        }
646    }
647    found
648}
649
650/// Whether an instruction value is the empty exec form — `[]`, with any spacing.
651fn is_empty_exec_form(value: &str) -> bool {
652    let mut meaningful = value.chars().filter(|c| !c.is_whitespace());
653    meaningful.next() == Some('[') && meaningful.next() == Some(']') && meaningful.next().is_none()
654}
655
656/// Rejects a Dockerfile whose image would never run the daemon: no `CMD`, or an
657/// `ENTRYPOINT` that swallows it.
658///
659/// The artifact unconditionally carries the daemon as entry `agentd` ([`build_artifact`]),
660/// and the deployment invariant is `ENTRYPOINT []` plus `CMD ["/agentd"]` — see
661/// [`default_dockerfile`]. A caller Dockerfile with no `CMD` runs the base's own default
662/// instead, and a non-empty `ENTRYPOINT` turns any `CMD` into that entrypoint's arguments
663/// rather than the process. Either way the daemon never starts, the build-time
664/// `ready`/`validate` hooks go unanswered, and the image lands in `CREATE_FAILED` — or the
665/// launch dies as a run-hook timeout — with nothing naming `CMD`, `ENTRYPOINT`, or the
666/// artifact entry.
667///
668/// Weak-form on purpose: it does **not** check that the `CMD` names a path anything was
669/// copied to. That would be Dockerfile interpretation rather than agreement checking, and
670/// the two mistakes people actually make are the two refused here. The unenforceable half —
671/// a base image that starts its own background process before bootstrap — stays with
672/// whoever builds the image (`docs/PROTOCOL.md`, "Trust boundary").
673pub fn require_daemon_cmd(dockerfile: &str) -> Result<(), Error> {
674    if let Some(entrypoint) = dockerfile_entrypoint(dockerfile)
675        && !is_empty_exec_form(entrypoint)
676    {
677        return Err(Error::invalid_arg(format!(
678            "the Dockerfile sets ENTRYPOINT {entrypoint}, which makes any CMD its arguments \
679             rather than the container's process — so the daemon this client uploads never \
680             starts. The build succeeds anyway: the ready/validate hooks just go unanswered \
681             and the image fails as CREATE_FAILED, or the launch dies as a run-hook timeout, \
682             and neither symptom names ENTRYPOINT. Set `ENTRYPOINT []` alongside \
683             `CMD [\"/agentd\"]`, or build with default_dockerfile, which sets both."
684        )));
685    }
686    match dockerfile_cmd(dockerfile) {
687        Some(cmd) if !cmd.is_empty() && !is_empty_exec_form(cmd) => Ok(()),
688        _ => Err(Error::invalid_arg(
689            "the Dockerfile has no CMD, so the container runs the base image's default \
690             process and the daemon this client uploads never starts. The build succeeds \
691             anyway: the ready/validate hooks just go unanswered and the image fails as \
692             CREATE_FAILED, or the launch dies as a run-hook timeout, and neither symptom \
693             names CMD. Add `ENTRYPOINT []` and `CMD [\"/agentd\"]`, or build with \
694             default_dockerfile, which sets both."
695                .to_string(),
696        )),
697    }
698}
699
700/// Rejects a caller Dockerfile that never mentions the lockfile the request carries.
701///
702/// The project files enter the artifact unconditionally once the request holds them, so a
703/// custom Dockerfile that ignores them builds cleanly and produces an image with **no
704/// environment layer** — and the symptom appears at launch, as every dependency
705/// installing inside the guest, with nothing anywhere naming the `COPY` that was never
706/// written. That is the silent-degradation shape: the caller asked for the layer, paid
707/// the build, and got an image indistinguishable from one that never asked.
708///
709/// Weak-form on purpose, like [`require_daemon_cmd`]: a substring test for the lockfile's
710/// name rather than parsing `COPY` syntax, because the check exists to catch a Dockerfile
711/// that plainly ignores the files, not to validate how it consumes them.
712pub fn require_project_install(project: &ProjectFiles, dockerfile: &str) -> Result<(), Error> {
713    let lockfile = project.ecosystem.lockfile_name();
714    if dockerfile.contains(lockfile) {
715        return Ok(());
716    }
717    Err(Error::invalid_arg(format!(
718        "the request carries project files ({lockfile} and {manifest}) but the Dockerfile \
719         never mentions {lockfile}, so the image would bake no environment layer: the build \
720         succeeds, and every launch still installs dependencies inside the guest with \
721         nothing naming the COPY that was never written. Copy the pair and install from \
722         the lockfile ({install}), or build with default_dockerfile, which derives both \
723         lines — or drop the project files if the layer is not wanted.",
724        manifest = project.ecosystem.manifest_name(),
725        install = match project.ecosystem {
726            Ecosystem::Uv => "uv sync --locked",
727            Ecosystem::Npm => "npm ci",
728            Ecosystem::Cargo => "cargo fetch --locked",
729        },
730    )))
731}
732
733/// Rejects a Dockerfile whose `FROM` is not the selected base image.
734///
735/// The build runs the Dockerfile *on top of* the base named in `baseImageArn`, so the two
736/// disagreeing produces an image built from something other than the platform base whose
737/// behaviour every `docs/PLATFORM.md` measurement describes — and nothing in the result
738/// says so.
739pub fn require_matching_from(base: &BaseImage, dockerfile: &str) -> Result<(), Error> {
740    let Some(found) = dockerfile_from_ref(dockerfile) else {
741        // No FROM at all is not this check's business: the build will say so, and a
742        // Dockerfile validator is not what this is.
743        return Ok(());
744    };
745    if found == base.docker_ref {
746        return Ok(());
747    }
748    // The digest-pinned spelling of the same ref: `<docker_ref>@sha256:<64 hex>`. A digest
749    // names one specific manifest of the ref this check already accepts, so it is a stricter
750    // statement of agreement rather than a disagreement — a supply-chain-conscious caller
751    // pins exactly this way, and refusing it would force them to un-pin to pass. The tag
752    // must still be present and identical: `alpine@sha256:...` against a docker_ref of
753    // `alpine:3` is a different claim and stays refused.
754    if let Some(digest) = found
755        .strip_prefix(base.docker_ref.as_str())
756        .and_then(|rest| rest.strip_prefix("@sha256:"))
757        && digest.len() == 64
758        && digest.bytes().all(|b| b.is_ascii_hexdigit())
759    {
760        return Ok(());
761    }
762    Err(Error::invalid_arg(format!(
763        "the Dockerfile's FROM is {found:?} but base image {:?} pairs with {:?}. These must \
764         agree: baseImageArn and the FROM select the same base, and a mismatch builds against \
765         a base none of the measured platform behaviour applies to. Use default_dockerfile \
766         with this base, or pass a BaseImage whose docker_ref matches.",
767        base.name, base.docker_ref
768    )))
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    /// The archive holds exactly the two entries the build expects, named as the build
776    /// looks for them.
777    #[test]
778    fn the_artifact_holds_a_dockerfile_and_the_daemon() {
779        let bytes = build_artifact(b"\x7fELF fake daemon", "FROM scratch\n", None).expect("zips");
780        let mut archive =
781            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
782
783        let mut names: Vec<String> = (0..archive.len())
784            .map(|index| archive.by_index(index).expect("entry").name().to_string())
785            .collect();
786        names.sort();
787        assert_eq!(names, ["Dockerfile", "agentd"]);
788    }
789
790    /// The daemon entry carries mode 0755, and the Dockerfile entry does not have to.
791    ///
792    /// Read back out of the archive rather than asserted on the constant, because the
793    /// constant being right and the zip entry carrying it are two different facts — and it
794    /// is the second one that turns into a run-hook timeout.
795    ///
796    /// The permission bits are masked out of the returned mode: `zip` reports the whole
797    /// Unix mode, so the entry reads as `0o100755` — `S_IFREG | 0o755`. Comparing the
798    /// unmasked value against `0o755` fails while the archive is perfectly correct, which
799    /// is a test that would have sent someone to debug the writer.
800    #[test]
801    fn the_daemon_entry_carries_the_execute_bit() {
802        let bytes = build_artifact(b"binary", "FROM scratch\n", None).expect("zips");
803        let mut archive =
804            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
805        let mode = archive
806            .by_name("agentd")
807            .expect("the daemon entry")
808            .unix_mode()
809            .expect("a mode was recorded");
810        assert_eq!(
811            mode & 0o777,
812            AGENTD_MODE,
813            "a non-executable binary surfaces as a run-hook timeout, not a permission error \
814             (mode was {mode:o})"
815        );
816        assert!(
817            mode & 0o111 != 0,
818            "the execute bit is the whole point: {mode:o}"
819        );
820    }
821
822    /// The bytes round-trip. A zip that holds a truncated or re-encoded binary produces an
823    /// image whose CMD fails for a reason no message names.
824    #[test]
825    fn the_daemon_bytes_survive_the_round_trip() {
826        use std::io::Read as _;
827
828        // Deliberately includes a NUL and a high byte, which is what a real ELF has and
829        // what a text-mode write would mangle.
830        let binary: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
831        let bytes = build_artifact(&binary, "FROM scratch\n", None).expect("zips");
832        let mut archive =
833            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
834        let mut read = Vec::new();
835        archive
836            .by_name("agentd")
837            .expect("entry")
838            .read_to_end(&mut read)
839            .expect("reads");
840        assert_eq!(read, binary);
841    }
842
843    /// **AC-2-3, the byte-scan guard.** The agent token must not appear anywhere in the
844    /// artifact's raw bytes — including the project-file entries #74 added.
845    ///
846    /// A byte scan rather than an API review, because the leak this guards against is a
847    /// *value* turning up somewhere — in the Dockerfile as an `ENV`, in a baked config
848    /// file, in a stray argument — not a parameter being declared. Scanning the compressed
849    /// bytes and the decompressed entries both, since deflate would hide a literal from a
850    /// naive scan of the archive. The artifact under scan carries a full project-file
851    /// pair, and the entry count is asserted so the scan cannot silently sweep fewer
852    /// members than the artifact holds.
853    ///
854    /// **Falsification** — add the token to the Dockerfile (`ENV AGENT_TOKEN=…`, the
855    /// plausible mistake) and the decompressed scan fails. Re-run for the #74 members
856    /// (2026-08-31): append the token to the lockfile entry inside `build_artifact` and
857    /// the per-entry scan fails naming `uv.lock`; it was restored.
858    #[test]
859    fn the_artifact_never_carries_the_agent_token() {
860        use std::io::Read as _;
861
862        let token = "s3cr3t-agent-token-do-not-bake-me";
863        let dockerfile = default_dockerfile(9000, Some("/opt/work"), &BaseImage::al2023(), None);
864        assert!(
865            !dockerfile.contains(token),
866            "the default Dockerfile must not mention a token"
867        );
868
869        let project = ProjectFiles {
870            ecosystem: Ecosystem::Uv,
871            manifest: b"[project]\nname = \"demo\"\n".to_vec(),
872            lockfile: b"version = 1\n".to_vec(),
873        };
874        let bytes = build_artifact(b"binary", &dockerfile, Some(&project)).expect("zips");
875        assert!(
876            !bytes
877                .windows(token.len())
878                .any(|window| window == token.as_bytes()),
879            "the token appears in the artifact's raw bytes"
880        );
881
882        let mut archive =
883            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
884        assert_eq!(
885            archive.len(),
886            4,
887            "the scan below must sweep every member this artifact can hold"
888        );
889        for index in 0..archive.len() {
890            let mut entry = archive.by_index(index).expect("entry");
891            let name = entry.name().to_string();
892            let mut content = Vec::new();
893            entry.read_to_end(&mut content).expect("reads");
894            assert!(
895                !content
896                    .windows(token.len())
897                    .any(|window| window == token.as_bytes()),
898                "the token appears inside {name} — the image snapshot is shared by every VM \
899                 launched from it, so a token here is a token shared with every VM"
900            );
901        }
902    }
903
904    /// **#74, the build-context half.** A project's manifest and lockfile enter the
905    /// archive under their ecosystem's fixed names, at the root beside the Dockerfile —
906    /// the build context root, where the generated `COPY` finds them — and their bytes
907    /// survive the round trip.
908    #[test]
909    fn project_files_enter_the_artifact_under_their_ecosystem_names() {
910        use std::io::Read as _;
911
912        for (ecosystem, manifest_name, lockfile_name) in [
913            (Ecosystem::Uv, "pyproject.toml", "uv.lock"),
914            (Ecosystem::Npm, "package.json", "package-lock.json"),
915            (Ecosystem::Cargo, "Cargo.toml", "Cargo.lock"),
916        ] {
917            let project = ProjectFiles {
918                ecosystem,
919                manifest: b"manifest-bytes".to_vec(),
920                lockfile: b"lockfile-bytes".to_vec(),
921            };
922            let bytes = build_artifact(b"binary", "FROM scratch\n", Some(&project)).expect("zips");
923            let mut archive =
924                zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
925
926            let mut names: Vec<String> = (0..archive.len())
927                .map(|index| archive.by_index(index).expect("entry").name().to_string())
928                .collect();
929            names.sort();
930            let mut expected = vec![
931                "Dockerfile".to_string(),
932                "agentd".to_string(),
933                manifest_name.to_string(),
934                lockfile_name.to_string(),
935            ];
936            expected.sort();
937            assert_eq!(names, expected, "{ecosystem:?}");
938
939            let mut lockfile = Vec::new();
940            archive
941                .by_name(lockfile_name)
942                .expect("the lockfile entry")
943                .read_to_end(&mut lockfile)
944                .expect("reads");
945            assert_eq!(lockfile, b"lockfile-bytes", "{ecosystem:?}");
946        }
947    }
948
949    /// **#74's TRAP-5 design guard, the compile surface.** [`ProjectFiles`] has no field
950    /// that could carry an entry *name* — the names come from the [`Ecosystem`] variant —
951    /// so a caller cannot put `.env` or a credentials file into the shared snapshot
952    /// through this type. Asserted by destructuring, the same shape as TRAP-1's
953    /// `no_request_type_carries_a_caller_supplied_client_token`: a `name`/`path` field
954    /// added later fails to compile this test.
955    #[test]
956    fn project_files_carry_no_caller_named_entry() {
957        let ProjectFiles {
958            ecosystem: _,
959            manifest: _,
960            lockfile: _,
961        } = ProjectFiles {
962            ecosystem: Ecosystem::Npm,
963            manifest: Vec::new(),
964            lockfile: Vec::new(),
965        };
966    }
967
968    /// The content hash is a pure function of the two inputs: equal inputs agree, either
969    /// input changing changes it, and the boundary between the two inputs is part of the
970    /// identity.
971    ///
972    /// The boundary case is the one worth spelling out: without length prefixes,
973    /// `("ab", "c")` and `("a", "bc")` hash the same concatenation — and a Dockerfile
974    /// edit could then collide with a binary edit, serving a stale image for changed
975    /// inputs, which is the exact hazard the hash exists to close.
976    #[test]
977    fn the_content_hash_follows_the_inputs_and_only_the_inputs() {
978        let hash = artifact_content_hash(b"binary-bytes", "FROM scratch\n", None);
979        assert_eq!(
980            hash,
981            artifact_content_hash(b"binary-bytes", "FROM scratch\n", None)
982        );
983        assert_eq!(hash.len(), 64, "sha256 as lowercase hex");
984        assert!(
985            hash.chars()
986                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
987        );
988
989        assert_ne!(
990            hash,
991            artifact_content_hash(b"other-bytes", "FROM scratch\n", None)
992        );
993        assert_ne!(
994            hash,
995            artifact_content_hash(b"binary-bytes", "FROM scratch\nRUN true\n", None)
996        );
997        assert_ne!(
998            artifact_content_hash(b"ab", "c", None),
999            artifact_content_hash(b"a", "bc", None),
1000            "the input boundary is part of the identity"
1001        );
1002
1003        // The projectless digest is pinned to the exact value this function produced
1004        // before #74. Every image name `build --reuse` ever derived embeds this digest's
1005        // first twelve characters, so changing the no-project stream silently orphans
1006        // every existing reuse — the module docs call that hazard out for the zip's own
1007        // bytes, and it applies to the algorithm the same way. Recomputed independently
1008        // (Python hashlib, 2026-08-31) rather than pasted from this function's output.
1009        assert_eq!(
1010            hash, "90a0ef925a4696b0c0f142241b2c714e63f40071d0d02951e26cb2a1d8bb24fc",
1011            "the no-project digest must stay what it was before #74"
1012        );
1013    }
1014
1015    /// **#74 step 3, the key.** The project files are part of the artifact identity:
1016    /// identical pairs agree, a lockfile edit is a new identity (and so a fresh image
1017    /// name and a fresh build), and the entry names count — the same bytes under a
1018    /// different ecosystem are a different identity, because the Dockerfile that consumes
1019    /// them installs a different environment.
1020    ///
1021    /// **Falsification** — run 2026-08-31 and again 2026-09-02: the lockfile's
1022    /// `hasher.update` lines were dropped from `artifact_content_hash` and the
1023    /// lockfile-edit assertion went red (two different lockfiles, one hash — the
1024    /// stale-reuse hazard verbatim); restored.
1025    #[test]
1026    fn the_content_hash_keys_on_the_lockfile() {
1027        let project = |ecosystem, lockfile: &[u8]| ProjectFiles {
1028            ecosystem,
1029            manifest: b"[project]".to_vec(),
1030            lockfile: lockfile.to_vec(),
1031        };
1032        let hash = artifact_content_hash(
1033            b"binary",
1034            "FROM scratch\n",
1035            Some(&project(Ecosystem::Uv, b"version = 1")),
1036        );
1037        assert_eq!(
1038            hash,
1039            artifact_content_hash(
1040                b"binary",
1041                "FROM scratch\n",
1042                Some(&project(Ecosystem::Uv, b"version = 1")),
1043            ),
1044            "identical dependency files share an image"
1045        );
1046        assert_ne!(
1047            hash,
1048            artifact_content_hash(
1049                b"binary",
1050                "FROM scratch\n",
1051                Some(&project(Ecosystem::Uv, b"version = 2")),
1052            ),
1053            "a lockfile edit is a new image name and a fresh build"
1054        );
1055        assert_ne!(
1056            hash,
1057            artifact_content_hash(b"binary", "FROM scratch\n", None),
1058            "carrying a layer is a different identity from carrying none"
1059        );
1060        assert_ne!(
1061            artifact_content_hash(
1062                b"binary",
1063                "FROM scratch\n",
1064                Some(&project(Ecosystem::Npm, b"version = 1")),
1065            ),
1066            hash,
1067            "the entry names count: the same bytes under another ecosystem install a \
1068             different environment"
1069        );
1070    }
1071
1072    /// The Dockerfile's `FROM` comes from the base image, so the two cannot disagree.
1073    #[test]
1074    fn the_default_dockerfile_derives_its_from_from_the_base_image() {
1075        let base = BaseImage::al2023();
1076        let dockerfile = default_dockerfile(9000, None, &base, None);
1077        assert_eq!(
1078            dockerfile_from_ref(&dockerfile),
1079            Some(base.docker_ref.as_str())
1080        );
1081        assert!(dockerfile.contains(r#"CMD ["/agentd"]"#));
1082        assert!(
1083            dockerfile.contains("ENTRYPOINT []"),
1084            "the trust boundary rests on this line"
1085        );
1086        assert!(dockerfile.contains("ENV AGENTD_PORT=9000"));
1087    }
1088
1089    /// A workdir is written as both a `mkdir` and a `WORKDIR`, and is absent when none was
1090    /// asked for. An empty string counts as none, since that is what an unset value looks
1091    /// like coming from a CLI flag.
1092    #[test]
1093    fn a_workdir_is_created_and_set_or_absent_entirely() {
1094        let base = BaseImage::al2023();
1095        let with = default_dockerfile(9000, Some("/opt/baked-workdir"), &base, None);
1096        assert!(with.contains("RUN mkdir -p /opt/baked-workdir"));
1097        assert!(with.contains("WORKDIR /opt/baked-workdir"));
1098
1099        for none in [None, Some("")] {
1100            let without = default_dockerfile(9000, none, &base, None);
1101            assert!(!without.contains("WORKDIR"), "{without}");
1102        }
1103    }
1104
1105    /// **#74 step 2, the install.** A project Dockerfile copies the ecosystem's pair into
1106    /// the working directory and installs from the lockfile with the lockfile-faithful
1107    /// spelling — `uv sync --locked`, `npm ci`, `cargo fetch --locked` — where the
1108    /// projectless Dockerfile installs nothing (the measured pre-#74 state).
1109    ///
1110    /// **Falsification** — run 2026-08-31: the `COPY` line was dropped from
1111    /// `default_dockerfile` and the copies-its-pair assertion went red for all three
1112    /// ecosystems; restored.
1113    #[test]
1114    fn a_project_dockerfile_installs_from_the_lockfile_it_can_see() {
1115        let base = BaseImage::al2023();
1116        for (ecosystem, install) in [
1117            (Ecosystem::Uv, "uv sync --locked"),
1118            (Ecosystem::Npm, "npm ci"),
1119            (Ecosystem::Cargo, "cargo fetch --locked"),
1120        ] {
1121            let dockerfile = default_dockerfile(9000, None, &base, Some(ecosystem));
1122            assert!(
1123                dockerfile.contains(&format!(
1124                    "COPY {} {} ./",
1125                    ecosystem.manifest_name(),
1126                    ecosystem.lockfile_name(),
1127                )),
1128                "copies its pair: {dockerfile}"
1129            );
1130            assert!(dockerfile.contains(install), "{dockerfile}");
1131            // The layer needs a directory even when the caller named none, because the
1132            // install resolves everything against the current directory.
1133            assert!(
1134                dockerfile.contains(&format!("WORKDIR {DEFAULT_PROJECT_WORKDIR}")),
1135                "{dockerfile}"
1136            );
1137
1138            // A caller-named workdir is where the layer lives instead.
1139            let placed = default_dockerfile(9000, Some("/srv/app"), &base, Some(ecosystem));
1140            assert!(placed.contains("WORKDIR /srv/app"), "{placed}");
1141            assert!(!placed.contains(DEFAULT_PROJECT_WORKDIR), "{placed}");
1142        }
1143
1144        let without = default_dockerfile(9000, None, &base, None);
1145        for install in ["uv sync", "npm ci", "cargo fetch", "COPY pyproject"] {
1146            assert!(!without.contains(install), "{without}");
1147        }
1148    }
1149
1150    /// The project Dockerfile passes every guard the plain one does: the `FROM` matches
1151    /// its base, the port agrees, the daemon stays the `CMD`, and the pair it copies
1152    /// satisfies [`require_project_install`]. A derived Dockerfile that failed its own
1153    /// preflight would refuse every `--project` build at the door.
1154    #[test]
1155    fn the_project_dockerfile_passes_its_own_guards() {
1156        let base = BaseImage::al2023();
1157        for ecosystem in Ecosystem::ALL {
1158            let dockerfile = default_dockerfile(9000, None, &base, Some(ecosystem));
1159            require_matching_from(&base, &dockerfile).expect("its FROM is its base");
1160            require_matching_agentd_port(9000, &dockerfile).expect("its port agrees");
1161            require_daemon_cmd(&dockerfile).expect("the daemon is still the CMD");
1162            let project = ProjectFiles {
1163                ecosystem,
1164                manifest: Vec::new(),
1165                lockfile: Vec::new(),
1166            };
1167            require_project_install(&project, &dockerfile)
1168                .expect("it copies the pair it was derived for");
1169        }
1170    }
1171
1172    /// **#74's silent-degradation guard.** A caller Dockerfile that never mentions the
1173    /// lockfile bakes no environment layer while building cleanly — the symptom is every
1174    /// launch still installing dependencies, with nothing naming the missing `COPY` — so
1175    /// it is refused up front, naming the lockfile, the install command, and both ways
1176    /// out.
1177    ///
1178    /// **Falsification** — run 2026-08-31: the `contains` check was inverted and both
1179    /// halves went red (the ignoring Dockerfile passed, the copying one was refused);
1180    /// restored.
1181    #[test]
1182    fn a_dockerfile_that_ignores_the_project_files_is_refused() {
1183        let project = ProjectFiles {
1184            ecosystem: Ecosystem::Npm,
1185            manifest: b"{}".to_vec(),
1186            lockfile: b"{}".to_vec(),
1187        };
1188        let error = require_project_install(
1189            &project,
1190            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1191             COPY agentd /agentd\n\
1192             ENTRYPOINT []\nCMD [\"/agentd\"]\n",
1193        )
1194        .expect_err("no mention of the lockfile bakes no layer");
1195        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1196        let message = error.to_string();
1197        assert!(message.contains("package-lock.json"), "{message}");
1198        assert!(message.contains("npm ci"), "{message}");
1199        assert!(message.contains("no environment layer"), "{message}");
1200
1201        require_project_install(
1202            &project,
1203            "FROM x\nCOPY package.json package-lock.json ./\nRUN npm ci\nCMD [\"/agentd\"]\n",
1204        )
1205        .expect("a Dockerfile that copies the pair agrees");
1206    }
1207
1208    /// `FROM` parsing tolerates the decoration a real Dockerfile carries: lowercase,
1209    /// leading whitespace, a `--platform` flag, and an `AS` alias.
1210    #[test]
1211    fn the_from_parser_ignores_decoration_rather_than_validating_syntax() {
1212        assert_eq!(dockerfile_from_ref("FROM alpine\n"), Some("alpine"));
1213        assert_eq!(dockerfile_from_ref("  from alpine:3\n"), Some("alpine:3"));
1214        assert_eq!(
1215            dockerfile_from_ref("FROM --platform=linux/arm64 alpine AS build\n"),
1216            Some("alpine")
1217        );
1218        assert_eq!(dockerfile_from_ref("RUN echo from nowhere\n"), None);
1219        assert_eq!(dockerfile_from_ref(""), None);
1220    }
1221
1222    /// A `FROM` that disagrees with the base image is refused, and the message names both
1223    /// refs — the one found and the one expected — because "these must agree" without both
1224    /// values leaves the caller to guess which to change.
1225    #[test]
1226    fn a_dockerfile_from_that_disagrees_with_the_base_is_refused() {
1227        let base = BaseImage::al2023();
1228        let error = require_matching_from(&base, "FROM ubuntu:24.04\nCOPY agentd /agentd\n")
1229            .expect_err("ubuntu is not the managed base");
1230        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1231        let message = error.to_string();
1232        assert!(message.contains("ubuntu:24.04"), "{message}");
1233        assert!(message.contains(&base.docker_ref), "{message}");
1234        assert!(
1235            message.contains("none of the measured platform behaviour applies"),
1236            "{message}"
1237        );
1238
1239        require_matching_from(&base, &default_dockerfile(9000, None, &base, None))
1240            .expect("the derived Dockerfile agrees with its own base");
1241    }
1242
1243    /// A digest-pinned `FROM` of the agreeing ref passes: it names one specific manifest of
1244    /// the base this check already accepts, which is the spelling a supply-chain-conscious
1245    /// caller uses. The boundary cases stay refused — a digest on a *different* ref is still
1246    /// a disagreement, and a malformed digest is not a digest.
1247    #[test]
1248    fn a_digest_pinned_from_of_the_agreeing_ref_passes() {
1249        let base = BaseImage::al2023();
1250        let digest = "c439fb4994ea7ca529233d6256446d3f8b7b4efb58956073e015303a170011de";
1251        let pinned = format!(
1252            "FROM {}@sha256:{digest}\nCOPY agentd /agentd\n",
1253            base.docker_ref
1254        );
1255        require_matching_from(&base, &pinned).expect("a digest pin of the same ref agrees");
1256
1257        require_matching_from(&base, &format!("FROM ubuntu:24.04@sha256:{digest}\n"))
1258            .expect_err("a digest does not launder a different ref");
1259        require_matching_from(&base, &format!("FROM {}@sha256:abc123\n", base.docker_ref))
1260            .expect_err("a 6-character digest is not a digest");
1261        require_matching_from(
1262            &base,
1263            &format!("FROM {}@sha256:{}\n", base.docker_ref, "g".repeat(64)),
1264        )
1265        .expect_err("64 non-hex characters are not a digest");
1266    }
1267
1268    /// A Dockerfile with no `FROM` is not this check's business: the build will say so, and
1269    /// refusing here would be a Dockerfile validator rather than an agreement check.
1270    #[test]
1271    fn a_dockerfile_with_no_from_is_left_to_the_build() {
1272        require_matching_from(&BaseImage::al2023(), "COPY agentd /agentd\n")
1273            .expect("no FROM is not a disagreement");
1274    }
1275
1276    /// The measured case, reproduced from the Dockerfile that spent it: a hand-written
1277    /// guest Dockerfile carrying `ENV AGENTD_PORT=8080` — the plausible port, and the wrong
1278    /// one — against a client sending `hooks.port=9000`. The build succeeds, the daemon logs
1279    /// `agentd listening`, and the image lands in `CREATE_FAILED` naming no port.
1280    #[test]
1281    fn a_dockerfile_port_that_disagrees_with_the_hook_port_is_refused() {
1282        let error = require_matching_agentd_port(
1283            crate::control::DEFAULT_AGENT_PORT,
1284            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1285             COPY agentd /agentd\n\
1286             ENV AGENTD_PORT=8080\n\
1287             EXPOSE 8080\n\
1288             CMD [\"/agentd\"]\n",
1289        )
1290        .expect_err("8080 is not the port the create call sends");
1291        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1292        let message = error.to_string();
1293        assert!(message.contains("8080"), "{message}");
1294        assert!(message.contains("9000"), "{message}");
1295        // The remedy is what a reader acts on, and the diagnostic's value is naming the
1296        // symptom that points away from the cause.
1297        assert!(message.contains("CREATE_FAILED"), "{message}");
1298        assert!(message.contains("ready/validate"), "{message}");
1299
1300        require_matching_agentd_port(
1301            crate::control::DEFAULT_AGENT_PORT,
1302            &default_dockerfile(
1303                crate::control::DEFAULT_AGENT_PORT,
1304                Some("/work"),
1305                &BaseImage::al2023(),
1306                None,
1307            ),
1308        )
1309        .expect("the derived Dockerfile agrees with the port it was derived from");
1310    }
1311
1312    /// Both `ENV` spellings set the variable, so a guard reading only `KEY=VALUE` would pass
1313    /// the legacy form it could not parse. The last assignment wins, as it does at build
1314    /// time.
1315    #[test]
1316    fn the_port_scan_reads_both_env_spellings_and_takes_the_last() {
1317        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=9000\n"), Some(9000));
1318        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT 8080\n"), Some(8080));
1319        assert_eq!(dockerfile_agentd_port("env agentd_port=7000\n"), None);
1320        assert_eq!(
1321            dockerfile_agentd_port("ENV AGENTD_PORT=8080\nENV AGENTD_PORT=9000\n"),
1322            Some(9000),
1323        );
1324        assert_eq!(dockerfile_agentd_port("ENV AGENTD_LOG=info\n"), None);
1325        // Neither a missing variable nor an unparseable one is a disagreement: the daemon
1326        // keeps its own default for both (`agentd/src/config.rs:118`).
1327        assert_eq!(
1328            dockerfile_agentd_port("FROM x\nCOPY agentd /agentd\n"),
1329            None
1330        );
1331        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=nine\n"), None);
1332        require_matching_agentd_port(
1333            crate::control::DEFAULT_AGENT_PORT,
1334            "FROM x\nCOPY agentd /agentd\n",
1335        )
1336        .expect("no variable agrees with the default, which is what the daemon will use");
1337    }
1338
1339    /// The other half of the pair, and the harder one to diagnose: a Dockerfile that names
1340    /// no port at all against a client that moved off the default. Silence is not neutral —
1341    /// `Config::from_env` keeps `9000` for an unset variable — so the guest listens on 9000
1342    /// while the hooks are dialled on the client's port, which is the measured failure with
1343    /// nothing in the Dockerfile to point at.
1344    #[test]
1345    fn a_dockerfile_naming_no_port_is_refused_when_the_client_moved_off_the_default() {
1346        let error = require_matching_agentd_port(
1347            8080,
1348            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1349             COPY agentd /agentd\n\
1350             CMD [\"/agentd\"]\n",
1351        )
1352        .expect_err("a silent Dockerfile leaves the daemon on 9000, not on 8080");
1353        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1354        let message = error.to_string();
1355        assert!(message.contains("8080"), "{message}");
1356        assert!(
1357            message.contains(&crate::control::DEFAULT_AGENT_PORT.to_string()),
1358            "the daemon's default is the value the reader has to learn: {message}"
1359        );
1360        assert!(message.contains("CREATE_FAILED"), "{message}");
1361        assert!(message.contains("ready/validate"), "{message}");
1362
1363        // An unparseable value has the same consequence in the guest as no value, so it is
1364        // refused for the same reason rather than passed as "not this guard's business".
1365        require_matching_agentd_port(8080, "FROM x\nENV AGENTD_PORT=nine\n")
1366            .expect_err("a value the daemon cannot parse leaves it on its own default");
1367
1368        // The default-port client is the common case and stays silent: the Dockerfile that
1369        // says nothing and the client that changed nothing already agree.
1370        require_matching_agentd_port(
1371            crate::control::DEFAULT_AGENT_PORT,
1372            "FROM x\nCOPY agentd /agentd\n",
1373        )
1374        .expect("silence agrees with the default");
1375    }
1376
1377    /// The fourth pair of the `FROM`/`WORKDIR`/`AGENTD_PORT` shape, found by sweeping for
1378    /// the others: a keepalive interval the client's silence tolerance is shorter than. The
1379    /// resulting error prints the client's own timeout as though it were the keepalive, so
1380    /// the number a reader would search for never appears.
1381    #[test]
1382    fn a_keepalive_at_or_over_the_client_idle_timeout_is_refused() {
1383        let timeout = crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT;
1384        assert_eq!(timeout.as_secs(), 60, "the guard's arithmetic assumes this");
1385
1386        let error =
1387            require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=90\n")
1388                .expect_err("90s of scheduled silence exceeds a 60s tolerance");
1389        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1390        let message = error.to_string();
1391        assert!(message.contains("90"), "{message}");
1392        assert!(message.contains("60"), "{message}");
1393
1394        // Equality races rather than passing: the timeout fires on the same tick the
1395        // keepalive is due.
1396        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=60\n")
1397            .expect_err("an interval equal to the tolerance is a race, not a margin");
1398
1399        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS 30\n")
1400            .expect("30s leaves a margin, in the legacy spelling");
1401        // The daemon's own default is 15s, so silence is safe for this pair.
1402        require_keepalive_under_idle_timeout(timeout, "FROM x\nCOPY agentd /agentd\n")
1403            .expect("an unset keepalive leaves the daemon at 15s");
1404        require_keepalive_under_idle_timeout(
1405            timeout,
1406            &default_dockerfile(
1407                crate::control::DEFAULT_AGENT_PORT,
1408                Some("/work"),
1409                &BaseImage::al2023(),
1410                None,
1411            ),
1412        )
1413        .expect("the derived Dockerfile sets no keepalive");
1414    }
1415
1416    /// **Issue #46, both refusable halves.** A Dockerfile with no `CMD` builds an image
1417    /// that runs the base's default process; a non-empty `ENTRYPOINT` turns the `CMD` into
1418    /// its arguments. Either way the daemon the artifact carries never starts, and the
1419    /// failure surfaces as `CREATE_FAILED` or a run-hook timeout naming neither
1420    /// instruction — so the message must name both the instruction and that symptom.
1421    #[test]
1422    fn a_dockerfile_that_never_runs_the_daemon_is_refused_naming_the_symptom() {
1423        // No CMD at all: the base's default applies, which is not /agentd.
1424        let error = require_daemon_cmd(
1425            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1426             COPY agentd /agentd\n\
1427             ENV AGENTD_PORT=9000\n",
1428        )
1429        .expect_err("no CMD means the daemon never starts");
1430        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1431        let message = error.to_string();
1432        assert!(message.contains("no CMD"), "{message}");
1433        assert!(message.contains("run-hook timeout"), "{message}");
1434        assert!(message.contains("CREATE_FAILED"), "{message}");
1435
1436        // A non-empty ENTRYPOINT swallows the CMD as its arguments.
1437        let error = require_daemon_cmd(
1438            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1439             COPY agentd /agentd\n\
1440             ENTRYPOINT [\"/bin/sh\", \"-c\"]\n\
1441             CMD [\"/agentd\"]\n",
1442        )
1443        .expect_err("a non-empty ENTRYPOINT makes CMD its arguments");
1444        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1445        let message = error.to_string();
1446        assert!(message.contains("ENTRYPOINT"), "{message}");
1447        assert!(message.contains("run-hook timeout"), "{message}");
1448
1449        // An empty CMD is the same absence in different spelling.
1450        require_daemon_cmd("FROM x\nCMD []\n").expect_err("CMD [] runs nothing");
1451    }
1452
1453    /// The shapes that must pass: the deployment invariant itself, in exec and spaced
1454    /// spellings, and the invariant's `ENTRYPOINT []` with any interior whitespace. The
1455    /// derived Dockerfile passes its own guard, which is what keeps the default path
1456    /// unaffected.
1457    #[test]
1458    fn the_deployment_invariant_passes_the_daemon_cmd_guard() {
1459        require_daemon_cmd(&default_dockerfile(
1460            9000,
1461            Some("/work"),
1462            &BaseImage::al2023(),
1463            None,
1464        ))
1465        .expect("the derived Dockerfile is the invariant");
1466        require_daemon_cmd("FROM x\nENTRYPOINT []\nCMD [\"/agentd\"]\n").expect("the invariant");
1467        require_daemon_cmd("FROM x\nENTRYPOINT [ ]\nCMD [\"/agentd\"]\n")
1468            .expect("interior whitespace is still the empty exec form");
1469        require_daemon_cmd("FROM x\ncmd [\"/agentd\"]\n").expect("case-insensitive, no ENTRYPOINT");
1470        // Last instruction wins, as at build time: a later empty ENTRYPOINT un-swallows.
1471        require_daemon_cmd("FROM x\nENTRYPOINT [\"/bin/sh\"]\nENTRYPOINT []\nCMD [\"/agentd\"]\n")
1472            .expect("the last ENTRYPOINT is the one the build uses");
1473        // And the reverse ordering is refused for the same reason.
1474        require_daemon_cmd("FROM x\nENTRYPOINT []\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n")
1475            .expect_err("the last ENTRYPOINT is non-empty");
1476    }
1477
1478    /// Workdir inheritance is refused when neither the base nor the Dockerfile declares
1479    /// one, and the message says where the symptom would have appeared — in the guest, a
1480    /// build cycle later.
1481    #[test]
1482    fn inheriting_a_workdir_nothing_declares_is_refused() {
1483        let base = BaseImage::al2023();
1484        assert!(base.working_dir.is_empty(), "the measured case");
1485
1486        let error = require_workdir(&base, None).expect_err("nothing declares a workdir");
1487        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1488        let message = error.to_string();
1489        assert!(message.contains("no WorkingDir"), "{message}");
1490        assert!(message.contains("nothing to inherit"), "{message}");
1491        assert!(message.contains("docs/PLATFORM.md"), "{message}");
1492    }
1493
1494    /// It is accepted when *either* side declares one — the Dockerfile's `WORKDIR` or the
1495    /// base image's own `working_dir`. Both directions, because a check that only read the
1496    /// Dockerfile would refuse a caller on a purpose-built image that declares one.
1497    #[test]
1498    fn either_the_dockerfile_or_the_base_may_supply_the_workdir() {
1499        let base = BaseImage::al2023();
1500        require_workdir(&base, Some("FROM x\nWORKDIR /srv\n"))
1501            .expect("the Dockerfile declares one");
1502
1503        let purpose_built = BaseImage {
1504            working_dir: "/app".to_string(),
1505            ..BaseImage::al2023()
1506        };
1507        require_workdir(&purpose_built, None).expect("the base image declares one");
1508    }
1509
1510    /// A bare `WORKDIR` with no argument does not count as declaring one. It is the shape a
1511    /// truncated edit leaves behind, and treating it as a declaration would pass the guard
1512    /// on a Dockerfile that sets nothing.
1513    #[test]
1514    fn a_workdir_with_no_argument_does_not_count_as_a_declaration() {
1515        let error = require_workdir(&BaseImage::al2023(), Some("FROM x\nWORKDIR\n"))
1516            .expect_err("WORKDIR with no path declares nothing");
1517        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1518    }
1519
1520    /// The base image ARN, for the region it is requested in.
1521    ///
1522    /// `microvm-image:` with a **colon**, which is the spelling for a managed base *and* for
1523    /// a customer image alike — measured 2026-08-15 against real ARNs in us-east-1, and the
1524    /// only form the model's `TaggableResource` pattern admits. The docs above this function
1525    /// carry the measurement.
1526    ///
1527    /// **Falsification** — change the separator to `/` and both assertions go red. That was
1528    /// the state twelve fakes and the transport encoding test were in, which is why this
1529    /// test now asserts the separator explicitly rather than only the region.
1530    #[test]
1531    fn the_base_image_arn_names_the_request_region_with_a_colon_separator() {
1532        assert_eq!(
1533            BaseImage::al2023().arn(&crate::region::Region::UsEast1),
1534            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1535        );
1536        assert_eq!(
1537            BaseImage::al2023().arn(&crate::region::Region::ApNortheast1),
1538            "arn:aws:lambda:ap-northeast-1:aws:microvm-image:al2023-1"
1539        );
1540        assert!(
1541            !BaseImage::al2023()
1542                .arn(&crate::region::Region::UsEast1)
1543                .contains("microvm-image/"),
1544            "the slash form answers AccessDeniedException, not a validation error"
1545        );
1546    }
1547}