Skip to main content

microvms_app/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    build_artifact_with_context(binary, dockerfile, project, None)
155}
156
157/// The mode every non-executable artifact entry carries.
158const FILE_MODE: u32 = 0o644;
159
160/// [`build_artifact`] plus a build context: the context's files at their relative paths
161/// beside the Dockerfile, which is the build context root, so the Dockerfile's `COPY` lines
162/// find them (IMAGE-7).
163///
164/// # One set of inputs, one object
165///
166/// Every entry carries the DOS epoch (1980-01-01) as its date and a fixed mode — `0o755` for
167/// the daemon and for context files with an execute bit, `0o644` for everything else — and
168/// the entries are written in a fixed order: `Dockerfile`, `agentd`, the project pair, then
169/// the context sorted by name. So equal inputs produce byte-identical archives on any host
170/// at any time, and a content-addressed S3 key holds one object rather than whichever
171/// upload landed last. (Byte identity across `zip` crate versions is not promised; the image
172/// name hashes the inputs, not these bytes, for exactly that reason — see
173/// [`artifact_content_hash`].)
174///
175/// Refuses a `binary` that isn't an aarch64 ELF, empty included (#257): every artifact
176/// carries the daemon as its `CMD`, and a build finds the wrong one only as a run-hook
177/// timeout. Refuses a context entry that repeats a project file's name, since one archive
178/// cannot hold two entries of one name.
179pub fn build_artifact_with_context(
180    binary: &[u8],
181    dockerfile: &str,
182    project: Option<&ProjectFiles>,
183    context: Option<&super::context::BuildContext>,
184) -> Result<Vec<u8>, Error> {
185    use zip::write::{SimpleFileOptions, ZipWriter};
186
187    microvms_domain::provision::require_aarch64(binary)?;
188    if let (Some(project), Some(context)) = (project, context)
189        && let Some(entry) = context.entries().iter().find(|entry| {
190            entry.name == project.ecosystem.manifest_name()
191                || entry.name == project.ecosystem.lockfile_name()
192        })
193    {
194        return Err(Error::invalid_arg(format!(
195            "the build context has {:?} at its root, which the project files already carry;              drop one of the two.",
196            entry.name
197        )));
198    }
199
200    let mut writer = ZipWriter::new(std::io::Cursor::new(Vec::new()));
201    let options = |mode: u32| {
202        SimpleFileOptions::default()
203            .compression_method(zip::CompressionMethod::Deflated)
204            .last_modified_time(zip::DateTime::default())
205            .unix_permissions(mode)
206    };
207
208    let mut write_entry = |name: &str, bytes: &[u8], mode: u32| {
209        writer
210            .start_file(name, options(mode))
211            .and_then(|()| writer.write_all(bytes).map_err(Into::into))
212            .map_err(|error| {
213                Error::new(
214                    ErrorKind::Unexpected,
215                    format!("could not add {name} to the build artifact: {error}"),
216                )
217            })
218    };
219
220    write_entry(DOCKERFILE_ENTRY, dockerfile.as_bytes(), FILE_MODE)?;
221    write_entry(AGENTD_ENTRY, binary, AGENTD_MODE)?;
222    if let Some(project) = project {
223        write_entry(
224            project.ecosystem.manifest_name(),
225            &project.manifest,
226            FILE_MODE,
227        )?;
228        write_entry(
229            project.ecosystem.lockfile_name(),
230            &project.lockfile,
231            FILE_MODE,
232        )?;
233    }
234    if let Some(context) = context {
235        for entry in context.entries() {
236            write_entry(&entry.name, &entry.bytes, entry.mode)?;
237        }
238    }
239
240    let bytes = writer
241        .finish()
242        .map_err(|error| {
243            Error::new(
244                ErrorKind::Unexpected,
245                format!("could not finish the build artifact: {error}"),
246            )
247        })?
248        .into_inner();
249    Ok(bytes)
250}
251
252/// The sha256 of the artifact's inputs, as lowercase hex.
253///
254/// # What is hashed, and why not the zip
255///
256/// The **inputs** — the daemon binary's bytes, the Dockerfile text, and the project
257/// files when the artifact carries them — rather than the bytes [`build_artifact`]
258/// produces. The zip is a container: its byte identity depends on the `zip` crate's
259/// version, its compression level, and its header defaults, so an upgraded dependency
260/// would silently change every image name and orphan every reuse. The inputs are what a
261/// build actually consumes, and two builds with equal inputs produce interchangeable
262/// images — which is the property content-addressed reuse rests on.
263///
264/// Each input is length-prefixed before hashing, so `(binary="ab", dockerfile="c")` and
265/// `(binary="a", dockerfile="bc")` are different hashes rather than one concatenation.
266/// A project file contributes its entry **name and bytes**, both length-prefixed, so the
267/// same bytes under a different ecosystem — `Cargo.lock` and `package-lock.json` holding
268/// identical text — are different identities too.
269///
270/// # `None` is the historical digest, deliberately
271///
272/// With no project files the digest stream is byte-identical to what this function
273/// produced before #74, so every projectless image name in every account survives the
274/// change — the alternative orphans exactly the reuse this hash exists to serve. The
275/// pinned vector in the tests is what holds that fixed. This is #74's key: two projects
276/// with identical lockfiles share an image, and a lockfile edit is a new name and a
277/// fresh build.
278pub fn artifact_content_hash(
279    binary: &[u8],
280    dockerfile: &str,
281    project: Option<&ProjectFiles>,
282) -> String {
283    artifact_content_hash_with_context(binary, dockerfile, project, None)
284}
285
286/// The tag that opens the build context's part of the digest stream.
287///
288/// A project file contributes a fixed file name, so no project entry can begin with this
289/// tag's length and bytes: the two sections cannot be confused for each other.
290const CONTEXT_TAG: &[u8] = b"microvms-build-context/1";
291
292/// [`artifact_content_hash`] extended with a build context (IMAGE-6).
293///
294/// With no context, or an empty one, the stream is byte-for-byte the one
295/// `artifact_content_hash` hashed before #221 — an empty context adds no entry, so it is the
296/// same artifact — and the pinned vector in the tests holds that fixed, so no `build --reuse`
297/// or `AgentVm` image name moves. A non-empty context appends [`CONTEXT_TAG`], the entry
298/// count, and each entry's name, mode, and bytes, the name and bytes length-prefixed. The
299/// entries are sorted by name ([`super::context::BuildContext`]), so the order a filesystem
300/// listed them in is not part of the identity; the mode is, because `COPY` keeps it.
301pub fn artifact_content_hash_with_context(
302    binary: &[u8],
303    dockerfile: &str,
304    project: Option<&ProjectFiles>,
305    context: Option<&super::context::BuildContext>,
306) -> String {
307    use sha2::{Digest as _, Sha256};
308
309    let mut hasher = Sha256::new();
310    hasher.update((binary.len() as u64).to_be_bytes());
311    hasher.update(binary);
312    hasher.update((dockerfile.len() as u64).to_be_bytes());
313    hasher.update(dockerfile.as_bytes());
314    if let Some(project) = project {
315        for (name, bytes) in [
316            (project.ecosystem.manifest_name(), &project.manifest),
317            (project.ecosystem.lockfile_name(), &project.lockfile),
318        ] {
319            hasher.update((name.len() as u64).to_be_bytes());
320            hasher.update(name.as_bytes());
321            hasher.update((bytes.len() as u64).to_be_bytes());
322            hasher.update(bytes);
323        }
324    }
325    if let Some(context) = context.filter(|context| !context.entries().is_empty()) {
326        hasher.update((CONTEXT_TAG.len() as u64).to_be_bytes());
327        hasher.update(CONTEXT_TAG);
328        hasher.update((context.entries().len() as u64).to_be_bytes());
329        for entry in context.entries() {
330            hasher.update((entry.name.len() as u64).to_be_bytes());
331            hasher.update(entry.name.as_bytes());
332            hasher.update(entry.mode.to_be_bytes());
333            hasher.update((entry.bytes.len() as u64).to_be_bytes());
334            hasher.update(&entry.bytes);
335        }
336    }
337    const_hex::encode(hasher.finalize())
338}
339
340/// A base image: the platform ARN, the Dockerfile `FROM` that pairs with it, and whether
341/// it declares a `WORKDIR`.
342///
343/// All three together because the first two **must** agree and used to be able to
344/// disagree: the Python client's `DEFAULT_BASE_IMAGE` named the managed base for
345/// `baseImageArn` while `default_dockerfile` hardcoded an unrelated registry literal in
346/// its `FROM`, so changing either left the other pointing somewhere else
347/// (`sandbox.py:410-444`). Pairing them means a caller selects one thing and both fields
348/// follow.
349#[derive(Clone, Debug, Eq, PartialEq)]
350pub struct BaseImage {
351    /// Goes into `baseImageArn` — the platform's managed base, not a registry ref.
352    pub name: String,
353    /// Goes into the Dockerfile `FROM` — the registry ref measured alongside `name`.
354    pub docker_ref: String,
355    /// What `docker inspect` reports for `WorkingDir`. Empty means it declares none.
356    ///
357    /// A field rather than a lookup because a caller with a purpose-built image is the
358    /// only one who can say what their image declares, and this client cannot read it
359    /// without pulling the manifest.
360    pub working_dir: String,
361}
362
363impl BaseImage {
364    /// The managed base every `docs/PLATFORM.md` measurement from 2026-08-06 onward used,
365    /// paired with the registry ref those same builds used as `FROM`.
366    ///
367    /// `working_dir` is empty, and that is measured rather than assumed:
368    /// `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave `WorkingDir`
369    /// empty (2026-08-05), which is what makes [`require_workdir`] necessary.
370    pub fn al2023() -> Self {
371        Self {
372            name: "al2023-1".to_string(),
373            docker_ref: "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal".to_string(),
374            working_dir: String::new(),
375        }
376    }
377
378    /// The base image a task Dockerfile pairs with: the managed base's `name` (so
379    /// `baseImageArn` is unchanged) and the Dockerfile's own first `FROM` as `docker_ref`
380    /// (IMAGE-4).
381    ///
382    /// # The pairing the other way round
383    ///
384    /// [`require_matching_from`] refuses a Dockerfile whose first `FROM` is not the base's
385    /// `docker_ref`. That is the right guard where this client derives the Dockerfile from
386    /// the base; a task Dockerfile chooses its own `FROM`, so the pairing has to run from the
387    /// Dockerfile to the base, and every harness wrote the same inversion by hand (#220).
388    /// Here the ref is taken whole — `--platform` and `AS` decoration dropped by
389    /// [`dockerfile_from_ref`], a digest pin kept — so the guard's comparison is the ref
390    /// against itself and it passes by construction.
391    ///
392    /// `working_dir` is empty: this cannot read the `FROM` image's `WorkingDir` without
393    /// pulling its manifest, and empty is what every measured public base declares (see
394    /// [`BaseImage::al2023`]). A task that relies on one sets `WORKDIR`, which
395    /// [`require_workdir`] reads from the Dockerfile.
396    ///
397    /// Refuses a Dockerfile with no `FROM`: there is no ref to take.
398    pub fn from_dockerfile(dockerfile: &str) -> Result<Self, Error> {
399        match dockerfile_from_ref(dockerfile) {
400            Some(docker_ref) => Ok(Self {
401                docker_ref: docker_ref.to_string(),
402                ..Self::al2023()
403            }),
404            None => Err(Error::invalid_arg(
405                "the Dockerfile has no FROM, so there is no image reference to pair the \
406                 managed base with. Derive the base from the Dockerfile the task builds \
407                 from."
408                    .to_string(),
409            )),
410        }
411    }
412
413    /// The `baseImageArn` for this base in `region`.
414    ///
415    /// # `microvm-image:<name>`, with a colon, and it is not just the managed base
416    ///
417    /// This was the only place in the repo building the colon form; the fakes and the
418    /// encoding test all used `microvm-image/<name>`, so the repo held two beliefs about the
419    /// shape of the identifier every image call takes. The model's `TaggableResource` pattern
420    /// admits only `(capacity-provider|network-connector|microvm-image):[a-zA-Z0-9-_]+`, and
421    /// a live read settled it: `ListMicrovmImages` in us-east-1 returns
422    /// `arn:aws:lambda:us-east-1:<account>:microvm-image:coding-agents-on-bedrock` for a
423    /// customer image, and `GetMicrovmImage` accepts exactly that (measured 2026-08-15).
424    ///
425    /// So the colon is right for **both** the managed base and a customer image; there is no
426    /// two-form rule, and this comment used to imply there was. The slash form fails as
427    /// `AccessDeniedException` rather than as a validation error, because IAM evaluates the
428    /// malformed ARN as a resource with no matching policy — a permissions message for a
429    /// resource that exists.
430    pub fn arn(&self, region: &crate::region::Region) -> String {
431        format!(
432            "arn:aws:lambda:{}:aws:microvm-image:{}",
433            region.as_str(),
434            self.name
435        )
436    }
437}
438
439impl Default for BaseImage {
440    fn default() -> Self {
441        Self::al2023()
442    }
443}
444
445/// Where the environment layer lives when the caller named no workdir of their own.
446///
447/// A constant rather than `/` because the install commands resolve everything against the
448/// current directory — `uv sync` writes `.venv` beside the manifest, `npm ci` writes
449/// `node_modules` — and the working tree synced at launch has to land in the same place
450/// for the layer to be *its* environment.
451pub const DEFAULT_PROJECT_WORKDIR: &str = "/project";
452
453/// A Dockerfile that makes the daemon the container `CMD`.
454///
455/// `ENTRYPOINT []` plus `CMD ["/agentd"]` is the deployment invariant the trust boundary
456/// rests on: it is what guarantees no task workload runs before the platform's run hook
457/// lands. It is also what makes an omitted `cwd` inherit the image `WORKDIR`, since the
458/// daemon's own cwd is the image's.
459///
460/// The `FROM` is derived from `base` rather than written here, so it cannot disagree with
461/// the `baseImageArn` the create call sends.
462///
463/// `project` bakes the environment layer (#74): the manifest+lockfile pair is copied into
464/// the working directory — `workdir` when given, [`DEFAULT_PROJECT_WORKDIR`] otherwise —
465/// and the ecosystem's [`Ecosystem::install_run_line`] installs from the lockfile. Without
466/// it the derived Dockerfile installs nothing, which was the measured state this closes:
467/// every launch then pays dependency installation inside the guest, the 31–48% of launch
468/// time `docs/STRATEGY.md` attributes to environment init.
469///
470/// The invariant is *unenforced*: a base image that starts its own background process
471/// before bootstrap breaks it, and enforcing that belongs to whoever builds the image
472/// (`docs/PROTOCOL.md`, "Trust boundary").
473pub fn default_dockerfile(
474    port: u16,
475    workdir: Option<&str>,
476    base: &BaseImage,
477    project: Option<Ecosystem>,
478) -> String {
479    format!(
480        "FROM {}\n{}",
481        base.docker_ref,
482        agentd_stanza(port, workdir, project)
483    )
484}
485
486/// The agentd stanza: every line [`default_dockerfile`] writes after its `FROM`, and every
487/// line [`wrap_dockerfile`] appends to a task Dockerfile (IMAGE-1).
488///
489/// # One source, and why it is this function rather than a constant
490///
491/// Two copies of these lines drift: a harness that carried the stanza as a string literal
492/// of its own (#220) had to track every change here by hand, and the day one side gained a
493/// line the other did not, the image a task built and the image the default build produced
494/// stopped agreeing about how the daemon starts. So both callers render this one function,
495/// and `wrapping_a_bare_from_is_the_default_dockerfile_minus_nothing` holds them equal:
496/// `wrap_dockerfile("FROM x\n")` is the default Dockerfile for a base whose ref is `x`.
497///
498/// The text is byte-for-byte what `default_dockerfile` emitted before the split, which is
499/// what keeps every content-addressed image name (`build --reuse`, `AgentVm::image_name`)
500/// derived from the default Dockerfile valid: the name hashes the Dockerfile text.
501fn agentd_stanza(port: u16, workdir: Option<&str>, project: Option<Ecosystem>) -> String {
502    let mut lines = vec![
503        "COPY agentd /agentd".to_string(),
504        "RUN chmod 0755 /agentd".to_string(),
505    ];
506    let workdir = match (workdir.filter(|dir| !dir.is_empty()), project) {
507        (Some(dir), _) => Some(dir),
508        // The layer needs a directory to live in even when the caller named none.
509        (None, Some(_)) => Some(DEFAULT_PROJECT_WORKDIR),
510        (None, None) => None,
511    };
512    if let Some(workdir) = workdir {
513        lines.push(format!("RUN mkdir -p {workdir}"));
514        lines.push(format!("WORKDIR {workdir}"));
515    }
516    if let Some(ecosystem) = project {
517        // Relative destination, resolving against the WORKDIR set above — the same
518        // directory the install command runs in.
519        lines.push(format!(
520            "COPY {} {} ./",
521            ecosystem.manifest_name(),
522            ecosystem.lockfile_name(),
523        ));
524        lines.push(ecosystem.install_run_line().to_string());
525    }
526    lines.extend([
527        format!("ENV AGENTD_PORT={port}"),
528        "ENV AGENTD_LOG=info".to_string(),
529        format!("EXPOSE {port}"),
530        "ENTRYPOINT []".to_string(),
531        r#"CMD ["/agentd"]"#.to_string(),
532        String::new(),
533    ]);
534    lines.join("\n")
535}
536
537/// What [`wrap_dockerfile`] needs besides the task text.
538#[derive(Clone, Debug, Eq, PartialEq)]
539pub struct WrapOptions {
540    /// The agent port the stanza's `ENV AGENTD_PORT` and `EXPOSE` name. It must be the port
541    /// the create call sends as `hooks.port` — [`ControlPlane::port`](super::ControlPlane::port)
542    /// — which [`require_matching_agentd_port`] checks at the create call.
543    pub port: u16,
544    /// A working directory for the stanza to create and set, exactly as
545    /// [`default_dockerfile`] writes one. One absolute path; `Some("")` reads as `None`, the
546    /// same way the default generator reads it.
547    pub workdir: Option<String>,
548    /// The caller relies on the image `WORKDIR` — an exec with no `cwd` runs there — so one
549    /// must be declared: by the task Dockerfile or by [`WrapOptions::workdir`]. Off by
550    /// default, because a task with no `WORKDIR` runs its commands in `/` under Docker too,
551    /// and a harness reproducing that has nothing to refuse.
552    pub inherit_workdir: bool,
553}
554
555impl Default for WrapOptions {
556    fn default() -> Self {
557        Self {
558            port: super::DEFAULT_AGENT_PORT,
559            workdir: None,
560            inherit_workdir: false,
561        }
562    }
563}
564
565/// A task Dockerfile with the agentd stanza appended: the one call a harness needs to go
566/// from the Dockerfile a task brings to one this client can build (#220).
567///
568/// The result is the task text verbatim, a newline if it lacked one, `USER root` when the
569/// task's last `USER` is anyone else, and then [`default_dockerfile`]'s stanza for
570/// `opts.port` and `opts.workdir` — the same function, so the two cannot drift (IMAGE-1).
571/// The stanza's `ENTRYPOINT []` and `CMD ["/agentd"]` are the result's last instructions,
572/// so whatever entrypoint, command, or port the task set, the daemon is the container's
573/// process and listens where the hooks are called (IMAGE-2). Pair it with
574/// [`BaseImage::from_dockerfile`] for the create call's `FROM` guard (IMAGE-4).
575///
576/// # `USER root`, only when the task changed user
577///
578/// The daemon demotes each exec to the user it names, which takes root; a task that ends on
579/// `USER app` would otherwise start the daemon as `app`. The line is written only when the
580/// last `USER` is not root, so a task that never changes user wraps to exactly the default
581/// stanza — writing it unconditionally would make the default Dockerfile and the wrap of a
582/// bare `FROM` two different texts (`crates/model/src/wrap.rs`, `AlwaysUserRoot`).
583///
584/// # What is refused, and why each is a refusal (IMAGE-3)
585///
586/// Each is a Dockerfile the build would accept and the guest would then fail on, one build
587/// cycle later and naming none of the cause:
588///
589/// * **No `FROM`.** Nothing to append to, and no base to derive.
590/// * **An unfinished last instruction.** A trailing line continuation — in the escape
591///   character the `# escape=` directive selects, across the blank and comment lines Docker
592///   skips inside one — joins the stanza's `COPY agentd /agentd` into the task's last
593///   instruction; an unterminated heredoc swallows the whole stanza as its body. Either way
594///   the image has no daemon and the build fails as a run-hook timeout.
595/// * **A keepalive at or over the client's stream idle timeout**, for the reason
596///   [`require_keepalive_under_idle_timeout`] gives.
597/// * **A port of 0, or a workdir that is not one absolute path.** A relative `WORKDIR`
598///   resolves against whatever the task left, whitespace splits `RUN mkdir -p` into several
599///   directories, and a line break writes an instruction the caller never saw.
600/// * **`inherit_workdir` with no `WORKDIR` anywhere**, for the reason [`require_workdir`]
601///   gives.
602///
603/// The scan reads instructions the way the Dockerfile parser does for these questions —
604/// escape directive, continuations, comments, heredoc bodies — and no further: it finds a
605/// Dockerfile that cannot take the stanza, it does not validate one.
606pub fn wrap_dockerfile(task: &str, opts: &WrapOptions) -> Result<String, Error> {
607    if dockerfile_from_ref(task).is_none() {
608        return Err(Error::invalid_arg(
609            "the task Dockerfile has no FROM, so there is no image to append the agentd \
610             stanza to and no base image to pair with it. Wrap the Dockerfile the task \
611             builds from, or pass `FROM <image>` for a task that names only an image."
612                .to_string(),
613        ));
614    }
615    let scan = Instructions::scan(task);
616    if let Some(terminator) = scan.open_heredoc {
617        return Err(Error::invalid_arg(format!(
618            "the task Dockerfile ends inside a heredoc that is never terminated (no line \
619             {terminator:?} closes it), so every line appended after it — the whole agentd \
620             stanza — would become the heredoc's body. The image would build with no daemon \
621             in it and fail as a run-hook timeout. Terminate the heredoc."
622        )));
623    }
624    if scan.continued {
625        return Err(Error::invalid_arg(format!(
626            "the task Dockerfile's last instruction ends with a line continuation ({:?}), so \
627             the first line appended after it — the agentd stanza's `COPY agentd /agentd` — \
628             would be joined into that instruction instead of running. The image would build \
629             with no daemon in it and fail as a run-hook timeout. Finish the instruction, or \
630             drop the trailing {:?}.",
631            scan.escape, scan.escape,
632        )));
633    }
634    require_keepalive_under_idle_timeout(crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT, task)?;
635    super::require_valid_port("port", opts.port)?;
636    let workdir = opts.workdir.as_deref().filter(|dir| !dir.is_empty());
637    if let Some(dir) = workdir
638        && (!dir.starts_with('/') || dir.chars().any(|c| c.is_whitespace() || c.is_control()))
639    {
640        return Err(Error::invalid_arg(format!(
641            "the workdir {dir:?} is not one absolute path. The stanza writes it as \
642             `RUN mkdir -p {dir}` and `WORKDIR {dir}`: a relative path resolves against \
643             whatever WORKDIR the task left, whitespace splits the mkdir into several \
644             directories, and a line break writes an instruction nobody reviewed. Pass an \
645             absolute path such as /workspace."
646        )));
647    }
648    if opts.inherit_workdir && workdir.is_none() && !dockerfile_declares_workdir(task) {
649        return Err(Error::invalid_arg(
650            "inherit_workdir was requested but the task Dockerfile sets no WORKDIR and no \
651             workdir option was given, so there is nothing to inherit: every exec with no cwd \
652             would run in `/`, and every relative path in it would resolve there. Pass a \
653             workdir, or set WORKDIR in the task Dockerfile."
654                .to_string(),
655        ));
656    }
657
658    let stanza = agentd_stanza(opts.port, workdir, None);
659    let mut wrapped = String::with_capacity(task.len() + stanza.len() + 16);
660    wrapped.push_str(task);
661    if !task.ends_with('\n') {
662        wrapped.push('\n');
663    }
664    if scan
665        .last_user
666        .as_deref()
667        .is_some_and(|user| !is_root_user(user))
668    {
669        wrapped.push_str("USER root\n");
670    }
671    wrapped.push_str(&stanza);
672    Ok(wrapped)
673}
674
675/// Whether a `USER` value names root: `root` or `0`, with no group or a root group.
676///
677/// Anything else is not root, including a value built from a variable — the scan cannot
678/// expand `${TASK_USER}`, and restoring root for a value that turns out to be root costs one
679/// redundant line.
680fn is_root_user(value: &str) -> bool {
681    let is_root = |part: &str| part == "root" || part == "0";
682    match value.split_once(':') {
683        Some((user, group)) => is_root(user) && is_root(group),
684        None => is_root(value),
685    }
686}
687
688/// Whether any line of `dockerfile` is a `WORKDIR` with an argument.
689fn dockerfile_declares_workdir(dockerfile: &str) -> bool {
690    dockerfile.lines().any(|line| {
691        let mut words = line.split_whitespace();
692        words
693            .next()
694            .is_some_and(|first| first.eq_ignore_ascii_case("WORKDIR"))
695            && words.next().is_some()
696    })
697}
698
699/// What [`wrap_dockerfile`] needs to know about how a Dockerfile's instructions end.
700struct Instructions {
701    /// The escape character: `\` unless an `# escape=` directive chose a backtick.
702    escape: char,
703    /// The text ends inside a line continuation.
704    continued: bool,
705    /// The terminator of a heredoc the text never closes.
706    open_heredoc: Option<String>,
707    /// The value of the last `USER` instruction.
708    last_user: Option<String>,
709}
710
711impl Instructions {
712    /// Reads the parser directives, then every logical instruction: continuation lines
713    /// joined, blank and comment lines skipped (inside a continuation too, as the parser
714    /// does), heredoc bodies consumed up to their terminators.
715    fn scan(dockerfile: &str) -> Self {
716        let escape = escape_directive(dockerfile);
717        let mut heredocs: std::collections::VecDeque<(String, bool)> = Default::default();
718        let mut pending = String::new();
719        let mut continued = false;
720        let mut last_user = None;
721        for line in dockerfile.lines() {
722            if let Some((terminator, strip_tabs)) = heredocs.front() {
723                let candidate = if *strip_tabs {
724                    line.trim_start_matches('\t')
725                } else {
726                    line
727                };
728                if candidate.trim_end_matches('\r') == terminator {
729                    heredocs.pop_front();
730                }
731                continue;
732            }
733            let trimmed = line.trim();
734            if trimmed.is_empty() || trimmed.starts_with('#') {
735                continue;
736            }
737            let body = line.trim_end();
738            if let Some(joined) = body.strip_suffix(escape) {
739                pending.push_str(joined);
740                pending.push(' ');
741                continued = true;
742                continue;
743            }
744            continued = false;
745            pending.push_str(body);
746            let instruction = std::mem::take(&mut pending);
747            let mut words = instruction.split_whitespace();
748            let Some(keyword) = words.next() else {
749                continue;
750            };
751            if keyword.eq_ignore_ascii_case("USER") {
752                last_user = words.next().map(str::to_string);
753            } else if ["RUN", "COPY", "ADD"]
754                .iter()
755                .any(|name| keyword.eq_ignore_ascii_case(name))
756            {
757                heredocs.extend(words.filter_map(heredoc_marker));
758            }
759        }
760        Self {
761            escape,
762            continued,
763            open_heredoc: heredocs.pop_front().map(|(terminator, _)| terminator),
764            last_user,
765        }
766    }
767}
768
769/// The escape character a Dockerfile's `# escape=` parser directive selects, or `\`.
770///
771/// Directives are only read at the very top: the first line that is not one — an
772/// instruction, a blank line, or an ordinary comment — ends them.
773fn escape_directive(dockerfile: &str) -> char {
774    for line in dockerfile.lines() {
775        let Some(directive) = line.trim().strip_prefix('#') else {
776            break;
777        };
778        let Some((key, value)) = directive.split_once('=') else {
779            break;
780        };
781        let key = key.trim().to_ascii_lowercase();
782        if key == "escape" {
783            return match value.trim() {
784                "`" => '`',
785                _ => '\\',
786            };
787        }
788        if !["syntax", "check"].contains(&key.as_str()) {
789            break;
790        }
791    }
792    '\\'
793}
794
795/// The terminator a heredoc marker word opens, and whether it strips leading tabs (`<<-`).
796///
797/// `<<EOF`, `<<-EOF`, `<<"EOF"`, `<<'EOF'`; not the here-string `<<<`, and not a bare `<<`.
798fn heredoc_marker(word: &str) -> Option<(String, bool)> {
799    let rest = word.strip_prefix("<<")?;
800    if rest.starts_with('<') {
801        return None;
802    }
803    let (rest, strip_tabs) = match rest.strip_prefix('-') {
804        Some(rest) => (rest, true),
805        None => (rest, false),
806    };
807    let name = rest
808        .strip_prefix('"')
809        .and_then(|name| name.strip_suffix('"'))
810        .or_else(|| {
811            rest.strip_prefix('\'')
812                .and_then(|name| name.strip_suffix('\''))
813        })
814        .unwrap_or(rest);
815    let valid = name
816        .chars()
817        .next()
818        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
819        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
820    valid.then(|| (name.to_string(), strip_tabs))
821}
822
823/// The image ref in a Dockerfile's first `FROM`, or `None` when it has none.
824///
825/// Deliberately loose on whitespace and case, and it ignores `--platform=` and `AS name`
826/// decoration, because the check exists to catch a base that disagrees rather than to
827/// validate Dockerfile syntax.
828///
829/// Split-and-scan rather than a regex: the pattern is "first token after FROM on a line
830/// whose first word is FROM", which `split_whitespace` states more directly than a
831/// pattern string would. (`regex-lite` is available if this ever grows.)
832pub fn dockerfile_from_ref(dockerfile: &str) -> Option<&str> {
833    for line in dockerfile.lines() {
834        let mut words = line.split_whitespace();
835        let Some(first) = words.next() else { continue };
836        if !first.eq_ignore_ascii_case("FROM") {
837            continue;
838        }
839        // Skip flag decoration such as `--platform=linux/arm64`.
840        return words.find(|word| !word.starts_with("--"));
841    }
842    None
843}
844
845/// Rejects working-directory inheritance when nothing declares one.
846///
847/// Measured 2026-08-05: `al2023-minimal`, `python:3.12-slim`, and `node:20-slim` all leave
848/// `WorkingDir` empty, so "inherit the image WORKDIR" inherits `/` and every relative path
849/// in the caller's commands resolves somewhere they did not mean.
850///
851/// Rejected rather than warned because the symptom appears in the **guest, one build cycle
852/// later**, as commands running in the wrong directory rather than as anything about
853/// `WORKDIR`.
854pub fn require_workdir(base: &BaseImage, dockerfile: Option<&str>) -> Result<(), Error> {
855    if !base.working_dir.is_empty() {
856        return Ok(());
857    }
858    let declares_workdir = dockerfile.is_some_and(|text| {
859        text.lines().any(|line| {
860            let mut words = line.split_whitespace();
861            words
862                .next()
863                .is_some_and(|first| first.eq_ignore_ascii_case("WORKDIR"))
864                && words.next().is_some()
865        })
866    });
867    if declares_workdir {
868        return Ok(());
869    }
870    Err(Error::invalid_arg(format!(
871        "inherit_workdir was requested but base image {:?} declares no WorkingDir and the \
872         Dockerfile sets none. Most public ARM64 base images leave it empty \
873         (docs/PLATFORM.md, 'Most public ARM64 base images have no WORKDIR'), so there is \
874         nothing to inherit and every relative path would resolve against `/`. Pass a workdir \
875         to default_dockerfile, or set WORKDIR in your own Dockerfile.",
876        base.name
877    )))
878}
879
880/// The port in a Dockerfile's last `ENV AGENTD_PORT=`, or `None` when it sets none.
881///
882/// Last rather than first: a later `ENV` of the same name wins at build time, so the last
883/// one is what the daemon reads. Accepts `ENV AGENTD_PORT=9000` and the legacy
884/// `ENV AGENTD_PORT 9000` spelling, since both set the variable and a guard that only
885/// understands one form passes the file it cannot parse.
886///
887/// Split-and-scan for the same reason [`dockerfile_from_ref`] is: `split_whitespace`
888/// states the pattern more directly than a regex string would here.
889pub fn dockerfile_agentd_port(dockerfile: &str) -> Option<u16> {
890    let mut found = None;
891    for line in dockerfile.lines() {
892        let mut words = line.split_whitespace();
893        let Some(first) = words.next() else { continue };
894        if !first.eq_ignore_ascii_case("ENV") {
895            continue;
896        }
897        let Some(assignment) = words.next() else {
898            continue;
899        };
900        let value = match assignment.split_once('=') {
901            Some(("AGENTD_PORT", value)) => value,
902            Some(_) => continue,
903            // `ENV AGENTD_PORT 9000`: the value is the next word.
904            None if assignment == "AGENTD_PORT" => match words.next() {
905                Some(value) => value,
906                None => continue,
907            },
908            None => continue,
909        };
910        // An unparseable value is not this guard's business: the daemon keeps its default
911        // for one (`crates/agentd/src/config.rs:118`), so there is no disagreement to report.
912        if let Ok(port) = value.parse() {
913            found = Some(port);
914        }
915    }
916    found
917}
918
919/// Rejects a Dockerfile whose `AGENTD_PORT` disagrees with the port the create call sends
920/// as `hooks.port`.
921///
922/// The two are set independently — `hooks.port` comes from `ControlPlane::port`, the
923/// variable from the caller's own Dockerfile — and the platform calls its build-time
924/// `ready`/`validate` hooks on the port in the create call. A guest listening elsewhere
925/// answers nothing, so the build fails.
926///
927/// Rejected rather than warned because the failure points away from its cause: the docker
928/// build succeeds, the log group holds a clean build *and* the daemon's own "agentd
929/// listening" line, and the image still lands in `CREATE_FAILED`. Neither
930/// `GetMicrovmImage` nor the build log names the port — only
931/// `GetMicrovmImageVersion`'s `hooks.port` does, compared by hand against the Dockerfile.
932///
933/// A Dockerfile that sets no `AGENTD_PORT` is checked against
934/// [`DEFAULT_AGENT_PORT`](crate::control::DEFAULT_AGENT_PORT) rather than passed, because
935/// silence is not neutral: `Config::from_env` keeps its own default for an unset variable
936/// (`crates/agentd/src/config.rs:118`, default `crates/agentd/src/config.rs:84`), so the guest listens on
937/// 9000 whether or not the Dockerfile mentions a port. Against a client that changed its
938/// port, the absent variable produces exactly the measured failure — and produces it for a
939/// caller who never typed a port anywhere, which is the harder version to diagnose.
940pub fn require_matching_agentd_port(port: u16, dockerfile: &str) -> Result<(), Error> {
941    // An unparseable value lands here too, and belongs here: the daemon warns and keeps the
942    // same default (`crates/agentd/src/config.rs:174-179`), so both spellings of "the Dockerfile
943    // named no usable port" have the same consequence in the guest.
944    let Some(found) = dockerfile_agentd_port(dockerfile) else {
945        if port == super::DEFAULT_AGENT_PORT {
946            return Ok(());
947        }
948        return Err(Error::invalid_arg(format!(
949            "this client sends hooks.port={port} but the Dockerfile sets no usable \
950             AGENTD_PORT, so the daemon will listen on its own default of {default}. These \
951             must agree: the platform calls the build-time ready/validate hooks on the port \
952             in the create call, and a daemon listening on {default} answers none of them — \
953             the docker build succeeds, the daemon logs that it is listening, and the image \
954             still fails with CREATE_FAILED naming no port. Add ENV AGENTD_PORT={port} to \
955             the Dockerfile, or build it with default_dockerfile, which derives the value \
956             from the same port.",
957            default = super::DEFAULT_AGENT_PORT,
958        )));
959    };
960    if found == port {
961        return Ok(());
962    }
963    Err(Error::invalid_arg(format!(
964        "the Dockerfile sets ENV AGENTD_PORT={found} but this client sends hooks.port={port}. \
965         These must agree: the platform calls the build-time ready/validate hooks on the port \
966         in the create call, and a daemon listening on {found} answers none of them — the \
967         docker build succeeds, the daemon logs that it is listening, and the image still \
968         fails with CREATE_FAILED naming no port. Set ENV AGENTD_PORT={port} in the \
969         Dockerfile, or build it with default_dockerfile, which derives the value from the \
970         same port."
971    )))
972}
973
974/// The seconds in a Dockerfile's last `ENV AGENTD_SSE_KEEPALIVE_SECS=`, or `None` when it
975/// sets none.
976///
977/// Same shape and same reasoning as [`dockerfile_agentd_port`]: last assignment wins, both
978/// `ENV` spellings, and an unparseable value reads as absent because the daemon warns and
979/// keeps its default for one (`crates/agentd/src/config.rs:174-179`).
980pub fn dockerfile_env_u64(dockerfile: &str, key: &str) -> Option<u64> {
981    let mut found = None;
982    for line in dockerfile.lines() {
983        let mut words = line.split_whitespace();
984        let Some(first) = words.next() else { continue };
985        if !first.eq_ignore_ascii_case("ENV") {
986            continue;
987        }
988        let Some(assignment) = words.next() else {
989            continue;
990        };
991        let value = match assignment.split_once('=') {
992            Some((name, value)) if name == key => value,
993            Some(_) => continue,
994            None if assignment == key => match words.next() {
995                Some(value) => value,
996                None => continue,
997            },
998            None => continue,
999        };
1000        if let Ok(parsed) = value.parse() {
1001            found = Some(parsed);
1002        }
1003    }
1004    found
1005}
1006
1007/// Rejects a Dockerfile whose `AGENTD_SSE_KEEPALIVE_SECS` is not shorter than the client's
1008/// stream idle timeout.
1009///
1010/// The fourth pair of this shape, found by sweeping for the other three. The daemon's SSE
1011/// keepalive interval and the client's tolerance for silence are set in different
1012/// repositories of truth — the interval in the caller's Dockerfile
1013/// (`crates/agentd/src/config.rs:139`, default 15s at `:95`), the tolerance in
1014/// [`DEFAULT_STREAM_IDLE_TIMEOUT`](crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT),
1015/// which is 60s *because* it is four times that 15. Raise the interval past the tolerance
1016/// and every attached stream treats a healthy connection as dead, reconnects
1017/// `max_reconnects` times, and fails.
1018///
1019/// The failure names both numbers and misattributes one of them: the client reports that the
1020/// stream "went silent for 60s, longer than the keepalive interval"
1021/// (`crates/microvms-edges/src/session/http.rs:223-227`), where 60 is its own timeout and the
1022/// keepalive interval is the number it does not know. So a reader is told the keepalive was
1023/// exceeded by a message that prints the wrong value for it, one build cycle after the
1024/// Dockerfile that caused it.
1025///
1026/// Equality is refused, not just excess: an interval exactly equal to the timeout races.
1027pub fn require_keepalive_under_idle_timeout(
1028    idle_timeout: Duration,
1029    dockerfile: &str,
1030) -> Result<(), Error> {
1031    let Some(secs) = dockerfile_env_u64(dockerfile, "AGENTD_SSE_KEEPALIVE_SECS") else {
1032        // Absent is safe here, unlike the port: the daemon's default of 15s is already
1033        // under every idle timeout this client will use, and a client that shortens its
1034        // timeout below 15s is not something a Dockerfile can be blamed for.
1035        return Ok(());
1036    };
1037    if secs < idle_timeout.as_secs() {
1038        return Ok(());
1039    }
1040    Err(Error::invalid_arg(format!(
1041        "the Dockerfile sets ENV AGENTD_SSE_KEEPALIVE_SECS={secs} but this client treats a \
1042         stream as dead after {timeout}s of silence. The keepalive must be shorter: the \
1043         daemon sends nothing between events except that keepalive, so an interval of \
1044         {secs}s makes a healthy stream look dead, and every attach reconnects until it \
1045         gives up. The error it raises then reports the client's own {timeout}s as though it \
1046         were the keepalive interval, so it names neither the Dockerfile nor {secs}. Leave \
1047         the variable unset for the daemon's 15s default, or keep it under {timeout}.",
1048        timeout = idle_timeout.as_secs(),
1049    )))
1050}
1051
1052/// The value of a Dockerfile's last `ENTRYPOINT`, or `None` when it sets none.
1053///
1054/// Last rather than first for the reason [`dockerfile_agentd_port`] gives: a later
1055/// instruction of the same name wins at build time, so the last one is what the container
1056/// runs. The value is the raw rest of the line — this scan finds a disagreement, it does
1057/// not parse Dockerfile syntax.
1058pub fn dockerfile_entrypoint(dockerfile: &str) -> Option<&str> {
1059    last_instruction_value(dockerfile, "ENTRYPOINT")
1060}
1061
1062/// The value of a Dockerfile's last `CMD`, or `None` when it sets none.
1063pub fn dockerfile_cmd(dockerfile: &str) -> Option<&str> {
1064    last_instruction_value(dockerfile, "CMD")
1065}
1066
1067/// The rest of the last line whose first word is `keyword`, trimmed, or `None`.
1068fn last_instruction_value<'a>(dockerfile: &'a str, keyword: &str) -> Option<&'a str> {
1069    let mut found = None;
1070    for line in dockerfile.lines() {
1071        let trimmed = line.trim_start();
1072        let Some(first) = trimmed.split_whitespace().next() else {
1073            continue;
1074        };
1075        if first.eq_ignore_ascii_case(keyword) {
1076            found = Some(trimmed[first.len()..].trim());
1077        }
1078    }
1079    found
1080}
1081
1082/// Whether an instruction value is the empty exec form — `[]`, with any spacing.
1083fn is_empty_exec_form(value: &str) -> bool {
1084    let mut meaningful = value.chars().filter(|c| !c.is_whitespace());
1085    meaningful.next() == Some('[') && meaningful.next() == Some(']') && meaningful.next().is_none()
1086}
1087
1088/// Rejects a Dockerfile whose image would never run the daemon: no `CMD`, or an
1089/// `ENTRYPOINT` that swallows it.
1090///
1091/// The artifact unconditionally carries the daemon as entry `agentd` ([`build_artifact`]),
1092/// and the deployment invariant is `ENTRYPOINT []` plus `CMD ["/agentd"]` — see
1093/// [`default_dockerfile`]. A caller Dockerfile with no `CMD` runs the base's own default
1094/// instead, and a non-empty `ENTRYPOINT` turns any `CMD` into that entrypoint's arguments
1095/// rather than the process. Either way the daemon never starts, the build-time
1096/// `ready`/`validate` hooks go unanswered, and the image lands in `CREATE_FAILED` — or the
1097/// launch dies as a run-hook timeout — with nothing naming `CMD`, `ENTRYPOINT`, or the
1098/// artifact entry.
1099///
1100/// Weak-form on purpose: it does **not** check that the `CMD` names a path anything was
1101/// copied to. That would be Dockerfile interpretation rather than agreement checking, and
1102/// the two mistakes people actually make are the two refused here. The unenforceable half —
1103/// a base image that starts its own background process before bootstrap — stays with
1104/// whoever builds the image (`docs/PROTOCOL.md`, "Trust boundary").
1105pub fn require_daemon_cmd(dockerfile: &str) -> Result<(), Error> {
1106    if let Some(entrypoint) = dockerfile_entrypoint(dockerfile)
1107        && !is_empty_exec_form(entrypoint)
1108    {
1109        return Err(Error::invalid_arg(format!(
1110            "the Dockerfile sets ENTRYPOINT {entrypoint}, which makes any CMD its arguments \
1111             rather than the container's process — so the daemon this client uploads never \
1112             starts. The build succeeds anyway: the ready/validate hooks just go unanswered \
1113             and the image fails as CREATE_FAILED, or the launch dies as a run-hook timeout, \
1114             and neither symptom names ENTRYPOINT. Set `ENTRYPOINT []` alongside \
1115             `CMD [\"/agentd\"]`, or build with default_dockerfile, which sets both."
1116        )));
1117    }
1118    match dockerfile_cmd(dockerfile) {
1119        Some(cmd) if !cmd.is_empty() && !is_empty_exec_form(cmd) => Ok(()),
1120        _ => Err(Error::invalid_arg(
1121            "the Dockerfile has no CMD, so the container runs the base image's default \
1122             process and the daemon this client uploads never starts. The build succeeds \
1123             anyway: the ready/validate hooks just go unanswered and the image fails as \
1124             CREATE_FAILED, or the launch dies as a run-hook timeout, and neither symptom \
1125             names CMD. Add `ENTRYPOINT []` and `CMD [\"/agentd\"]`, or build with \
1126             default_dockerfile, which sets both."
1127                .to_string(),
1128        )),
1129    }
1130}
1131
1132/// Rejects a caller Dockerfile that never mentions the lockfile the request carries.
1133///
1134/// The project files enter the artifact unconditionally once the request holds them, so a
1135/// custom Dockerfile that ignores them builds cleanly and produces an image with **no
1136/// environment layer** — and the symptom appears at launch, as every dependency
1137/// installing inside the guest, with nothing anywhere naming the `COPY` that was never
1138/// written. That is the silent-degradation shape: the caller asked for the layer, paid
1139/// the build, and got an image indistinguishable from one that never asked.
1140///
1141/// Weak-form on purpose, like [`require_daemon_cmd`]: a substring test for the lockfile's
1142/// name rather than parsing `COPY` syntax, because the check exists to catch a Dockerfile
1143/// that plainly ignores the files, not to validate how it consumes them.
1144pub fn require_project_install(project: &ProjectFiles, dockerfile: &str) -> Result<(), Error> {
1145    let lockfile = project.ecosystem.lockfile_name();
1146    if dockerfile.contains(lockfile) {
1147        return Ok(());
1148    }
1149    Err(Error::invalid_arg(format!(
1150        "the request carries project files ({lockfile} and {manifest}) but the Dockerfile \
1151         never mentions {lockfile}, so the image would bake no environment layer: the build \
1152         succeeds, and every launch still installs dependencies inside the guest with \
1153         nothing naming the COPY that was never written. Copy the pair and install from \
1154         the lockfile ({install}), or build with default_dockerfile, which derives both \
1155         lines — or drop the project files if the layer is not wanted.",
1156        manifest = project.ecosystem.manifest_name(),
1157        install = match project.ecosystem {
1158            Ecosystem::Uv => "uv sync --locked",
1159            Ecosystem::Npm => "npm ci",
1160            Ecosystem::Cargo => "cargo fetch --locked",
1161        },
1162    )))
1163}
1164
1165/// Rejects a Dockerfile whose `FROM` is not the selected base image.
1166///
1167/// The build runs the Dockerfile *on top of* the base named in `baseImageArn`, so the two
1168/// disagreeing produces an image built from something other than the platform base whose
1169/// behaviour every `docs/PLATFORM.md` measurement describes — and nothing in the result
1170/// says so.
1171pub fn require_matching_from(base: &BaseImage, dockerfile: &str) -> Result<(), Error> {
1172    let Some(found) = dockerfile_from_ref(dockerfile) else {
1173        // No FROM at all is not this check's business: the build will say so, and a
1174        // Dockerfile validator is not what this is.
1175        return Ok(());
1176    };
1177    if found == base.docker_ref {
1178        return Ok(());
1179    }
1180    // The digest-pinned spelling of the same ref: `<docker_ref>@sha256:<64 hex>`. A digest
1181    // names one specific manifest of the ref this check already accepts, so it is a stricter
1182    // statement of agreement rather than a disagreement — a supply-chain-conscious caller
1183    // pins exactly this way, and refusing it would force them to un-pin to pass. The tag
1184    // must still be present and identical: `alpine@sha256:...` against a docker_ref of
1185    // `alpine:3` is a different claim and stays refused.
1186    if let Some(digest) = found
1187        .strip_prefix(base.docker_ref.as_str())
1188        .and_then(|rest| rest.strip_prefix("@sha256:"))
1189        && digest.len() == 64
1190        && digest.bytes().all(|b| b.is_ascii_hexdigit())
1191    {
1192        return Ok(());
1193    }
1194    Err(Error::invalid_arg(format!(
1195        "the Dockerfile's FROM is {found:?} but base image {:?} pairs with {:?}. These must \
1196         agree: baseImageArn and the FROM select the same base, and a mismatch builds against \
1197         a base none of the measured platform behaviour applies to. Use default_dockerfile \
1198         with this base, or pass a BaseImage whose docker_ref matches.",
1199        base.name, base.docker_ref
1200    )))
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206    use crate::testing::aarch64_daemon;
1207
1208    const DEFAULT_AGENT_PORT_FOR_TESTS: u16 = crate::control::DEFAULT_AGENT_PORT;
1209
1210    /// The archive holds exactly the two entries the build expects, named as the build
1211    /// looks for them.
1212    #[test]
1213    fn the_artifact_holds_a_dockerfile_and_the_daemon() {
1214        let bytes =
1215            build_artifact(&aarch64_daemon(b"fake daemon"), "FROM scratch\n", None).expect("zips");
1216        let mut archive =
1217            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
1218
1219        let mut names: Vec<String> = (0..archive.len())
1220            .map(|index| archive.by_index(index).expect("entry").name().to_string())
1221            .collect();
1222        names.sort();
1223        assert_eq!(names, ["Dockerfile", "agentd"]);
1224    }
1225
1226    /// **BIND-20 where the daemon enters an artifact (#257).** An x86_64 ELF, a script and no bytes
1227    /// at all are each refused as a precondition naming what the bytes are, so the bindings'
1228    /// two-step path (`build_artifact`, then the caller's own upload) never uploads one.
1229    ///
1230    /// **Falsification**: drop the `require_aarch64` call from `build_artifact_with_context`
1231    /// and all three zip.
1232    #[test]
1233    fn an_artifact_refuses_a_daemon_that_is_not_an_aarch64_elf() {
1234        for (binary, why) in [
1235            (crate::testing::elf_daemon(0x3E, b"x86"), "ELF machine 0x3e"),
1236            (b"#!/bin/sh\n".to_vec(), "not an ELF binary"),
1237            (Vec::new(), "not an ELF binary"),
1238        ] {
1239            let error = build_artifact(&binary, "FROM scratch\n", None).expect_err(why);
1240            assert_eq!(error.kind(), ErrorKind::Precondition, "{error}");
1241            assert!(error.to_string().contains(why), "{error}");
1242        }
1243    }
1244
1245    /// The daemon entry carries mode 0755, and the Dockerfile entry does not have to.
1246    ///
1247    /// Read back out of the archive rather than asserted on the constant, because the
1248    /// constant being right and the zip entry carrying it are two different facts — and it
1249    /// is the second one that turns into a run-hook timeout.
1250    ///
1251    /// The permission bits are masked out of the returned mode: `zip` reports the whole
1252    /// Unix mode, so the entry reads as `0o100755` — `S_IFREG | 0o755`. Comparing the
1253    /// unmasked value against `0o755` fails while the archive is perfectly correct, which
1254    /// is a test that would have sent someone to debug the writer.
1255    #[test]
1256    fn the_daemon_entry_carries_the_execute_bit() {
1257        let bytes =
1258            build_artifact(&aarch64_daemon(b"binary"), "FROM scratch\n", None).expect("zips");
1259        let mut archive =
1260            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
1261        let mode = archive
1262            .by_name("agentd")
1263            .expect("the daemon entry")
1264            .unix_mode()
1265            .expect("a mode was recorded");
1266        assert_eq!(
1267            mode & 0o777,
1268            AGENTD_MODE,
1269            "a non-executable binary surfaces as a run-hook timeout, not a permission error \
1270             (mode was {mode:o})"
1271        );
1272        assert!(
1273            mode & 0o111 != 0,
1274            "the execute bit is the whole point: {mode:o}"
1275        );
1276    }
1277
1278    /// The bytes round-trip. A zip that holds a truncated or re-encoded binary produces an
1279    /// image whose CMD fails for a reason no message names.
1280    #[test]
1281    fn the_daemon_bytes_survive_the_round_trip() {
1282        use std::io::Read as _;
1283
1284        // Deliberately includes a NUL and a high byte, which is what a real ELF has and
1285        // what a text-mode write would mangle.
1286        let binary = aarch64_daemon(&(0u8..=255).cycle().take(4096).collect::<Vec<u8>>());
1287        let bytes = build_artifact(&binary, "FROM scratch\n", None).expect("zips");
1288        let mut archive =
1289            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
1290        let mut read = Vec::new();
1291        archive
1292            .by_name("agentd")
1293            .expect("entry")
1294            .read_to_end(&mut read)
1295            .expect("reads");
1296        assert_eq!(read, binary);
1297    }
1298
1299    /// **AC-2-3, the byte-scan guard.** The agent token must not appear anywhere in the
1300    /// artifact's raw bytes — including the project-file entries #74 added.
1301    ///
1302    /// A byte scan rather than an API review, because the leak this guards against is a
1303    /// *value* turning up somewhere — in the Dockerfile as an `ENV`, in a baked config
1304    /// file, in a stray argument — not a parameter being declared. Scanning the compressed
1305    /// bytes and the decompressed entries both, since deflate would hide a literal from a
1306    /// naive scan of the archive. The artifact under scan carries a full project-file
1307    /// pair, and the entry count is asserted so the scan cannot silently sweep fewer
1308    /// members than the artifact holds.
1309    ///
1310    /// **Falsification** — add the token to the Dockerfile (`ENV AGENT_TOKEN=…`, the
1311    /// plausible mistake) and the decompressed scan fails. Re-run for the #74 members
1312    /// (2026-08-31): append the token to the lockfile entry inside `build_artifact` and
1313    /// the per-entry scan fails naming `uv.lock`; it was restored.
1314    #[test]
1315    fn the_artifact_never_carries_the_agent_token() {
1316        use std::io::Read as _;
1317
1318        let token = "s3cr3t-agent-token-do-not-bake-me";
1319        let dockerfile = default_dockerfile(9000, Some("/opt/work"), &BaseImage::al2023(), None);
1320        assert!(
1321            !dockerfile.contains(token),
1322            "the default Dockerfile must not mention a token"
1323        );
1324
1325        let project = ProjectFiles {
1326            ecosystem: Ecosystem::Uv,
1327            manifest: b"[project]\nname = \"demo\"\n".to_vec(),
1328            lockfile: b"version = 1\n".to_vec(),
1329        };
1330        let bytes =
1331            build_artifact(&aarch64_daemon(b"binary"), &dockerfile, Some(&project)).expect("zips");
1332        assert!(
1333            !bytes
1334                .windows(token.len())
1335                .any(|window| window == token.as_bytes()),
1336            "the token appears in the artifact's raw bytes"
1337        );
1338
1339        let mut archive =
1340            zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
1341        assert_eq!(
1342            archive.len(),
1343            4,
1344            "the scan below must sweep every member this artifact can hold"
1345        );
1346        for index in 0..archive.len() {
1347            let mut entry = archive.by_index(index).expect("entry");
1348            let name = entry.name().to_string();
1349            let mut content = Vec::new();
1350            entry.read_to_end(&mut content).expect("reads");
1351            assert!(
1352                !content
1353                    .windows(token.len())
1354                    .any(|window| window == token.as_bytes()),
1355                "the token appears inside {name} — the image snapshot is shared by every VM \
1356                 launched from it, so a token here is a token shared with every VM"
1357            );
1358        }
1359    }
1360
1361    /// **#74, the build-context half.** A project's manifest and lockfile enter the
1362    /// archive under their ecosystem's fixed names, at the root beside the Dockerfile —
1363    /// the build context root, where the generated `COPY` finds them — and their bytes
1364    /// survive the round trip.
1365    #[test]
1366    fn project_files_enter_the_artifact_under_their_ecosystem_names() {
1367        use std::io::Read as _;
1368
1369        for (ecosystem, manifest_name, lockfile_name) in [
1370            (Ecosystem::Uv, "pyproject.toml", "uv.lock"),
1371            (Ecosystem::Npm, "package.json", "package-lock.json"),
1372            (Ecosystem::Cargo, "Cargo.toml", "Cargo.lock"),
1373        ] {
1374            let project = ProjectFiles {
1375                ecosystem,
1376                manifest: b"manifest-bytes".to_vec(),
1377                lockfile: b"lockfile-bytes".to_vec(),
1378            };
1379            let bytes =
1380                build_artifact(&aarch64_daemon(b"binary"), "FROM scratch\n", Some(&project))
1381                    .expect("zips");
1382            let mut archive =
1383                zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip");
1384
1385            let mut names: Vec<String> = (0..archive.len())
1386                .map(|index| archive.by_index(index).expect("entry").name().to_string())
1387                .collect();
1388            names.sort();
1389            let mut expected = vec![
1390                "Dockerfile".to_string(),
1391                "agentd".to_string(),
1392                manifest_name.to_string(),
1393                lockfile_name.to_string(),
1394            ];
1395            expected.sort();
1396            assert_eq!(names, expected, "{ecosystem:?}");
1397
1398            let mut lockfile = Vec::new();
1399            archive
1400                .by_name(lockfile_name)
1401                .expect("the lockfile entry")
1402                .read_to_end(&mut lockfile)
1403                .expect("reads");
1404            assert_eq!(lockfile, b"lockfile-bytes", "{ecosystem:?}");
1405        }
1406    }
1407
1408    /// **#74's TRAP-5 design guard, the compile surface.** [`ProjectFiles`] has no field
1409    /// that could carry an entry *name* — the names come from the [`Ecosystem`] variant —
1410    /// so a caller cannot put `.env` or a credentials file into the shared snapshot
1411    /// through this type. Asserted by destructuring, the same shape as TRAP-1's
1412    /// `no_request_type_carries_a_caller_supplied_client_token`: a `name`/`path` field
1413    /// added later fails to compile this test.
1414    #[test]
1415    fn project_files_carry_no_caller_named_entry() {
1416        let ProjectFiles {
1417            ecosystem: _,
1418            manifest: _,
1419            lockfile: _,
1420        } = ProjectFiles {
1421            ecosystem: Ecosystem::Npm,
1422            manifest: Vec::new(),
1423            lockfile: Vec::new(),
1424        };
1425    }
1426
1427    /// The content hash is a pure function of the two inputs: equal inputs agree, either
1428    /// input changing changes it, and the boundary between the two inputs is part of the
1429    /// identity.
1430    ///
1431    /// The boundary case is the one worth spelling out: without length prefixes,
1432    /// `("ab", "c")` and `("a", "bc")` hash the same concatenation — and a Dockerfile
1433    /// edit could then collide with a binary edit, serving a stale image for changed
1434    /// inputs, which is the exact hazard the hash exists to close.
1435    #[test]
1436    fn the_content_hash_follows_the_inputs_and_only_the_inputs() {
1437        let hash = artifact_content_hash(b"binary-bytes", "FROM scratch\n", None);
1438        assert_eq!(
1439            hash,
1440            artifact_content_hash(b"binary-bytes", "FROM scratch\n", None)
1441        );
1442        assert_eq!(hash.len(), 64, "sha256 as lowercase hex");
1443        assert!(
1444            hash.chars()
1445                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1446        );
1447
1448        assert_ne!(
1449            hash,
1450            artifact_content_hash(b"other-bytes", "FROM scratch\n", None)
1451        );
1452        assert_ne!(
1453            hash,
1454            artifact_content_hash(b"binary-bytes", "FROM scratch\nRUN true\n", None)
1455        );
1456        assert_ne!(
1457            artifact_content_hash(b"ab", "c", None),
1458            artifact_content_hash(b"a", "bc", None),
1459            "the input boundary is part of the identity"
1460        );
1461
1462        // The projectless digest is pinned to the exact value this function produced
1463        // before #74. Every image name `build --reuse` ever derived embeds this digest's
1464        // first twelve characters, so changing the no-project stream silently orphans
1465        // every existing reuse — the module docs call that hazard out for the zip's own
1466        // bytes, and it applies to the algorithm the same way. Recomputed independently
1467        // (Python hashlib, 2026-08-31) rather than pasted from this function's output.
1468        assert_eq!(
1469            hash, "90a0ef925a4696b0c0f142241b2c714e63f40071d0d02951e26cb2a1d8bb24fc",
1470            "the no-project digest must stay what it was before #74"
1471        );
1472    }
1473
1474    /// **#74 step 3, the key.** The project files are part of the artifact identity:
1475    /// identical pairs agree, a lockfile edit is a new identity (and so a fresh image
1476    /// name and a fresh build), and the entry names count — the same bytes under a
1477    /// different ecosystem are a different identity, because the Dockerfile that consumes
1478    /// them installs a different environment.
1479    ///
1480    /// **Falsification** — run 2026-08-31 and again 2026-09-02: the lockfile's
1481    /// `hasher.update` lines were dropped from `artifact_content_hash` and the
1482    /// lockfile-edit assertion went red (two different lockfiles, one hash — the
1483    /// stale-reuse hazard verbatim); restored.
1484    #[test]
1485    fn the_content_hash_keys_on_the_lockfile() {
1486        let project = |ecosystem, lockfile: &[u8]| ProjectFiles {
1487            ecosystem,
1488            manifest: b"[project]".to_vec(),
1489            lockfile: lockfile.to_vec(),
1490        };
1491        let hash = artifact_content_hash(
1492            b"binary",
1493            "FROM scratch\n",
1494            Some(&project(Ecosystem::Uv, b"version = 1")),
1495        );
1496        assert_eq!(
1497            hash,
1498            artifact_content_hash(
1499                b"binary",
1500                "FROM scratch\n",
1501                Some(&project(Ecosystem::Uv, b"version = 1")),
1502            ),
1503            "identical dependency files share an image"
1504        );
1505        assert_ne!(
1506            hash,
1507            artifact_content_hash(
1508                b"binary",
1509                "FROM scratch\n",
1510                Some(&project(Ecosystem::Uv, b"version = 2")),
1511            ),
1512            "a lockfile edit is a new image name and a fresh build"
1513        );
1514        assert_ne!(
1515            hash,
1516            artifact_content_hash(b"binary", "FROM scratch\n", None),
1517            "carrying a layer is a different identity from carrying none"
1518        );
1519        assert_ne!(
1520            artifact_content_hash(
1521                b"binary",
1522                "FROM scratch\n",
1523                Some(&project(Ecosystem::Npm, b"version = 1")),
1524            ),
1525            hash,
1526            "the entry names count: the same bytes under another ecosystem install a \
1527             different environment"
1528        );
1529    }
1530
1531    /// The Dockerfile's `FROM` comes from the base image, so the two cannot disagree.
1532    #[test]
1533    fn the_default_dockerfile_derives_its_from_from_the_base_image() {
1534        let base = BaseImage::al2023();
1535        let dockerfile = default_dockerfile(9000, None, &base, None);
1536        assert_eq!(
1537            dockerfile_from_ref(&dockerfile),
1538            Some(base.docker_ref.as_str())
1539        );
1540        assert!(dockerfile.contains(r#"CMD ["/agentd"]"#));
1541        assert!(
1542            dockerfile.contains("ENTRYPOINT []"),
1543            "the trust boundary rests on this line"
1544        );
1545        assert!(dockerfile.contains("ENV AGENTD_PORT=9000"));
1546    }
1547
1548    /// A workdir is written as both a `mkdir` and a `WORKDIR`, and is absent when none was
1549    /// asked for. An empty string counts as none, since that is what an unset value looks
1550    /// like coming from a CLI flag.
1551    #[test]
1552    fn a_workdir_is_created_and_set_or_absent_entirely() {
1553        let base = BaseImage::al2023();
1554        let with = default_dockerfile(9000, Some("/opt/baked-workdir"), &base, None);
1555        assert!(with.contains("RUN mkdir -p /opt/baked-workdir"));
1556        assert!(with.contains("WORKDIR /opt/baked-workdir"));
1557
1558        for none in [None, Some("")] {
1559            let without = default_dockerfile(9000, none, &base, None);
1560            assert!(!without.contains("WORKDIR"), "{without}");
1561        }
1562    }
1563
1564    /// **#74 step 2, the install.** A project Dockerfile copies the ecosystem's pair into
1565    /// the working directory and installs from the lockfile with the lockfile-faithful
1566    /// spelling — `uv sync --locked`, `npm ci`, `cargo fetch --locked` — where the
1567    /// projectless Dockerfile installs nothing (the measured pre-#74 state).
1568    ///
1569    /// **Falsification** — run 2026-08-31: the `COPY` line was dropped from
1570    /// `default_dockerfile` and the copies-its-pair assertion went red for all three
1571    /// ecosystems; restored.
1572    #[test]
1573    fn a_project_dockerfile_installs_from_the_lockfile_it_can_see() {
1574        let base = BaseImage::al2023();
1575        for (ecosystem, install) in [
1576            (Ecosystem::Uv, "uv sync --locked"),
1577            (Ecosystem::Npm, "npm ci"),
1578            (Ecosystem::Cargo, "cargo fetch --locked"),
1579        ] {
1580            let dockerfile = default_dockerfile(9000, None, &base, Some(ecosystem));
1581            assert!(
1582                dockerfile.contains(&format!(
1583                    "COPY {} {} ./",
1584                    ecosystem.manifest_name(),
1585                    ecosystem.lockfile_name(),
1586                )),
1587                "copies its pair: {dockerfile}"
1588            );
1589            assert!(dockerfile.contains(install), "{dockerfile}");
1590            // The layer needs a directory even when the caller named none, because the
1591            // install resolves everything against the current directory.
1592            assert!(
1593                dockerfile.contains(&format!("WORKDIR {DEFAULT_PROJECT_WORKDIR}")),
1594                "{dockerfile}"
1595            );
1596
1597            // A caller-named workdir is where the layer lives instead.
1598            let placed = default_dockerfile(9000, Some("/srv/app"), &base, Some(ecosystem));
1599            assert!(placed.contains("WORKDIR /srv/app"), "{placed}");
1600            assert!(!placed.contains(DEFAULT_PROJECT_WORKDIR), "{placed}");
1601        }
1602
1603        let without = default_dockerfile(9000, None, &base, None);
1604        for install in ["uv sync", "npm ci", "cargo fetch", "COPY pyproject"] {
1605            assert!(!without.contains(install), "{without}");
1606        }
1607    }
1608
1609    /// The project Dockerfile passes every guard the plain one does: the `FROM` matches
1610    /// its base, the port agrees, the daemon stays the `CMD`, and the pair it copies
1611    /// satisfies [`require_project_install`]. A derived Dockerfile that failed its own
1612    /// preflight would refuse every `--project` build at the door.
1613    #[test]
1614    fn the_project_dockerfile_passes_its_own_guards() {
1615        let base = BaseImage::al2023();
1616        for ecosystem in Ecosystem::ALL {
1617            let dockerfile = default_dockerfile(9000, None, &base, Some(ecosystem));
1618            require_matching_from(&base, &dockerfile).expect("its FROM is its base");
1619            require_matching_agentd_port(9000, &dockerfile).expect("its port agrees");
1620            require_daemon_cmd(&dockerfile).expect("the daemon is still the CMD");
1621            let project = ProjectFiles {
1622                ecosystem,
1623                manifest: Vec::new(),
1624                lockfile: Vec::new(),
1625            };
1626            require_project_install(&project, &dockerfile)
1627                .expect("it copies the pair it was derived for");
1628        }
1629    }
1630
1631    /// **#74's silent-degradation guard.** A caller Dockerfile that never mentions the
1632    /// lockfile bakes no environment layer while building cleanly — the symptom is every
1633    /// launch still installing dependencies, with nothing naming the missing `COPY` — so
1634    /// it is refused up front, naming the lockfile, the install command, and both ways
1635    /// out.
1636    ///
1637    /// **Falsification** — run 2026-08-31: the `contains` check was inverted and both
1638    /// halves went red (the ignoring Dockerfile passed, the copying one was refused);
1639    /// restored.
1640    #[test]
1641    fn a_dockerfile_that_ignores_the_project_files_is_refused() {
1642        let project = ProjectFiles {
1643            ecosystem: Ecosystem::Npm,
1644            manifest: b"{}".to_vec(),
1645            lockfile: b"{}".to_vec(),
1646        };
1647        let error = require_project_install(
1648            &project,
1649            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1650             COPY agentd /agentd\n\
1651             ENTRYPOINT []\nCMD [\"/agentd\"]\n",
1652        )
1653        .expect_err("no mention of the lockfile bakes no layer");
1654        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1655        let message = error.to_string();
1656        assert!(message.contains("package-lock.json"), "{message}");
1657        assert!(message.contains("npm ci"), "{message}");
1658        assert!(message.contains("no environment layer"), "{message}");
1659
1660        require_project_install(
1661            &project,
1662            "FROM x\nCOPY package.json package-lock.json ./\nRUN npm ci\nCMD [\"/agentd\"]\n",
1663        )
1664        .expect("a Dockerfile that copies the pair agrees");
1665    }
1666
1667    /// `FROM` parsing tolerates the decoration a real Dockerfile carries: lowercase,
1668    /// leading whitespace, a `--platform` flag, and an `AS` alias.
1669    #[test]
1670    fn the_from_parser_ignores_decoration_rather_than_validating_syntax() {
1671        assert_eq!(dockerfile_from_ref("FROM alpine\n"), Some("alpine"));
1672        assert_eq!(dockerfile_from_ref("  from alpine:3\n"), Some("alpine:3"));
1673        assert_eq!(
1674            dockerfile_from_ref("FROM --platform=linux/arm64 alpine AS build\n"),
1675            Some("alpine")
1676        );
1677        assert_eq!(dockerfile_from_ref("RUN echo from nowhere\n"), None);
1678        assert_eq!(dockerfile_from_ref(""), None);
1679    }
1680
1681    /// A `FROM` that disagrees with the base image is refused, and the message names both
1682    /// refs — the one found and the one expected — because "these must agree" without both
1683    /// values leaves the caller to guess which to change.
1684    #[test]
1685    fn a_dockerfile_from_that_disagrees_with_the_base_is_refused() {
1686        let base = BaseImage::al2023();
1687        let error = require_matching_from(&base, "FROM ubuntu:24.04\nCOPY agentd /agentd\n")
1688            .expect_err("ubuntu is not the managed base");
1689        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1690        let message = error.to_string();
1691        assert!(message.contains("ubuntu:24.04"), "{message}");
1692        assert!(message.contains(&base.docker_ref), "{message}");
1693        assert!(
1694            message.contains("none of the measured platform behaviour applies"),
1695            "{message}"
1696        );
1697
1698        require_matching_from(&base, &default_dockerfile(9000, None, &base, None))
1699            .expect("the derived Dockerfile agrees with its own base");
1700    }
1701
1702    /// A digest-pinned `FROM` of the agreeing ref passes: it names one specific manifest of
1703    /// the base this check already accepts, which is the spelling a supply-chain-conscious
1704    /// caller uses. The boundary cases stay refused — a digest on a *different* ref is still
1705    /// a disagreement, and a malformed digest is not a digest.
1706    #[test]
1707    fn a_digest_pinned_from_of_the_agreeing_ref_passes() {
1708        let base = BaseImage::al2023();
1709        let digest = "c439fb4994ea7ca529233d6256446d3f8b7b4efb58956073e015303a170011de";
1710        let pinned = format!(
1711            "FROM {}@sha256:{digest}\nCOPY agentd /agentd\n",
1712            base.docker_ref
1713        );
1714        require_matching_from(&base, &pinned).expect("a digest pin of the same ref agrees");
1715
1716        require_matching_from(&base, &format!("FROM ubuntu:24.04@sha256:{digest}\n"))
1717            .expect_err("a digest does not launder a different ref");
1718        require_matching_from(&base, &format!("FROM {}@sha256:abc123\n", base.docker_ref))
1719            .expect_err("a 6-character digest is not a digest");
1720        require_matching_from(
1721            &base,
1722            &format!("FROM {}@sha256:{}\n", base.docker_ref, "g".repeat(64)),
1723        )
1724        .expect_err("64 non-hex characters are not a digest");
1725    }
1726
1727    /// A Dockerfile with no `FROM` is not this check's business: the build will say so, and
1728    /// refusing here would be a Dockerfile validator rather than an agreement check.
1729    #[test]
1730    fn a_dockerfile_with_no_from_is_left_to_the_build() {
1731        require_matching_from(&BaseImage::al2023(), "COPY agentd /agentd\n")
1732            .expect("no FROM is not a disagreement");
1733    }
1734
1735    /// The measured case, reproduced from the Dockerfile that spent it: a hand-written
1736    /// guest Dockerfile carrying `ENV AGENTD_PORT=8080` — the plausible port, and the wrong
1737    /// one — against a client sending `hooks.port=9000`. The build succeeds, the daemon logs
1738    /// `agentd listening`, and the image lands in `CREATE_FAILED` naming no port.
1739    #[test]
1740    fn a_dockerfile_port_that_disagrees_with_the_hook_port_is_refused() {
1741        let error = require_matching_agentd_port(
1742            crate::control::DEFAULT_AGENT_PORT,
1743            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1744             COPY agentd /agentd\n\
1745             ENV AGENTD_PORT=8080\n\
1746             EXPOSE 8080\n\
1747             CMD [\"/agentd\"]\n",
1748        )
1749        .expect_err("8080 is not the port the create call sends");
1750        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1751        let message = error.to_string();
1752        assert!(message.contains("8080"), "{message}");
1753        assert!(message.contains("9000"), "{message}");
1754        // The remedy is what a reader acts on, and the diagnostic's value is naming the
1755        // symptom that points away from the cause.
1756        assert!(message.contains("CREATE_FAILED"), "{message}");
1757        assert!(message.contains("ready/validate"), "{message}");
1758
1759        require_matching_agentd_port(
1760            crate::control::DEFAULT_AGENT_PORT,
1761            &default_dockerfile(
1762                crate::control::DEFAULT_AGENT_PORT,
1763                Some("/work"),
1764                &BaseImage::al2023(),
1765                None,
1766            ),
1767        )
1768        .expect("the derived Dockerfile agrees with the port it was derived from");
1769    }
1770
1771    /// Both `ENV` spellings set the variable, so a guard reading only `KEY=VALUE` would pass
1772    /// the legacy form it could not parse. The last assignment wins, as it does at build
1773    /// time.
1774    #[test]
1775    fn the_port_scan_reads_both_env_spellings_and_takes_the_last() {
1776        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=9000\n"), Some(9000));
1777        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT 8080\n"), Some(8080));
1778        assert_eq!(dockerfile_agentd_port("env agentd_port=7000\n"), None);
1779        assert_eq!(
1780            dockerfile_agentd_port("ENV AGENTD_PORT=8080\nENV AGENTD_PORT=9000\n"),
1781            Some(9000),
1782        );
1783        assert_eq!(dockerfile_agentd_port("ENV AGENTD_LOG=info\n"), None);
1784        // Neither a missing variable nor an unparseable one is a disagreement: the daemon
1785        // keeps its own default for both (`crates/agentd/src/config.rs:118`).
1786        assert_eq!(
1787            dockerfile_agentd_port("FROM x\nCOPY agentd /agentd\n"),
1788            None
1789        );
1790        assert_eq!(dockerfile_agentd_port("ENV AGENTD_PORT=nine\n"), None);
1791        require_matching_agentd_port(
1792            crate::control::DEFAULT_AGENT_PORT,
1793            "FROM x\nCOPY agentd /agentd\n",
1794        )
1795        .expect("no variable agrees with the default, which is what the daemon will use");
1796    }
1797
1798    /// The other half of the pair, and the harder one to diagnose: a Dockerfile that names
1799    /// no port at all against a client that moved off the default. Silence is not neutral —
1800    /// `Config::from_env` keeps `9000` for an unset variable — so the guest listens on 9000
1801    /// while the hooks are dialled on the client's port, which is the measured failure with
1802    /// nothing in the Dockerfile to point at.
1803    #[test]
1804    fn a_dockerfile_naming_no_port_is_refused_when_the_client_moved_off_the_default() {
1805        let error = require_matching_agentd_port(
1806            8080,
1807            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1808             COPY agentd /agentd\n\
1809             CMD [\"/agentd\"]\n",
1810        )
1811        .expect_err("a silent Dockerfile leaves the daemon on 9000, not on 8080");
1812        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1813        let message = error.to_string();
1814        assert!(message.contains("8080"), "{message}");
1815        assert!(
1816            message.contains(&crate::control::DEFAULT_AGENT_PORT.to_string()),
1817            "the daemon's default is the value the reader has to learn: {message}"
1818        );
1819        assert!(message.contains("CREATE_FAILED"), "{message}");
1820        assert!(message.contains("ready/validate"), "{message}");
1821
1822        // An unparseable value has the same consequence in the guest as no value, so it is
1823        // refused for the same reason rather than passed as "not this guard's business".
1824        require_matching_agentd_port(8080, "FROM x\nENV AGENTD_PORT=nine\n")
1825            .expect_err("a value the daemon cannot parse leaves it on its own default");
1826
1827        // The default-port client is the common case and stays silent: the Dockerfile that
1828        // says nothing and the client that changed nothing already agree.
1829        require_matching_agentd_port(
1830            crate::control::DEFAULT_AGENT_PORT,
1831            "FROM x\nCOPY agentd /agentd\n",
1832        )
1833        .expect("silence agrees with the default");
1834    }
1835
1836    /// The fourth pair of the `FROM`/`WORKDIR`/`AGENTD_PORT` shape, found by sweeping for
1837    /// the others: a keepalive interval the client's silence tolerance is shorter than. The
1838    /// resulting error prints the client's own timeout as though it were the keepalive, so
1839    /// the number a reader would search for never appears.
1840    #[test]
1841    fn a_keepalive_at_or_over_the_client_idle_timeout_is_refused() {
1842        let timeout = crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT;
1843        assert_eq!(timeout.as_secs(), 60, "the guard's arithmetic assumes this");
1844
1845        let error =
1846            require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=90\n")
1847                .expect_err("90s of scheduled silence exceeds a 60s tolerance");
1848        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1849        let message = error.to_string();
1850        assert!(message.contains("90"), "{message}");
1851        assert!(message.contains("60"), "{message}");
1852
1853        // Equality races rather than passing: the timeout fires on the same tick the
1854        // keepalive is due.
1855        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS=60\n")
1856            .expect_err("an interval equal to the tolerance is a race, not a margin");
1857
1858        require_keepalive_under_idle_timeout(timeout, "ENV AGENTD_SSE_KEEPALIVE_SECS 30\n")
1859            .expect("30s leaves a margin, in the legacy spelling");
1860        // The daemon's own default is 15s, so silence is safe for this pair.
1861        require_keepalive_under_idle_timeout(timeout, "FROM x\nCOPY agentd /agentd\n")
1862            .expect("an unset keepalive leaves the daemon at 15s");
1863        require_keepalive_under_idle_timeout(
1864            timeout,
1865            &default_dockerfile(
1866                crate::control::DEFAULT_AGENT_PORT,
1867                Some("/work"),
1868                &BaseImage::al2023(),
1869                None,
1870            ),
1871        )
1872        .expect("the derived Dockerfile sets no keepalive");
1873    }
1874
1875    /// **Issue #46, both refusable halves.** A Dockerfile with no `CMD` builds an image
1876    /// that runs the base's default process; a non-empty `ENTRYPOINT` turns the `CMD` into
1877    /// its arguments. Either way the daemon the artifact carries never starts, and the
1878    /// failure surfaces as `CREATE_FAILED` or a run-hook timeout naming neither
1879    /// instruction — so the message must name both the instruction and that symptom.
1880    #[test]
1881    fn a_dockerfile_that_never_runs_the_daemon_is_refused_naming_the_symptom() {
1882        // No CMD at all: the base's default applies, which is not /agentd.
1883        let error = require_daemon_cmd(
1884            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1885             COPY agentd /agentd\n\
1886             ENV AGENTD_PORT=9000\n",
1887        )
1888        .expect_err("no CMD means the daemon never starts");
1889        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1890        let message = error.to_string();
1891        assert!(message.contains("no CMD"), "{message}");
1892        assert!(message.contains("run-hook timeout"), "{message}");
1893        assert!(message.contains("CREATE_FAILED"), "{message}");
1894
1895        // A non-empty ENTRYPOINT swallows the CMD as its arguments.
1896        let error = require_daemon_cmd(
1897            "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1898             COPY agentd /agentd\n\
1899             ENTRYPOINT [\"/bin/sh\", \"-c\"]\n\
1900             CMD [\"/agentd\"]\n",
1901        )
1902        .expect_err("a non-empty ENTRYPOINT makes CMD its arguments");
1903        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1904        let message = error.to_string();
1905        assert!(message.contains("ENTRYPOINT"), "{message}");
1906        assert!(message.contains("run-hook timeout"), "{message}");
1907
1908        // An empty CMD is the same absence in different spelling.
1909        require_daemon_cmd("FROM x\nCMD []\n").expect_err("CMD [] runs nothing");
1910    }
1911
1912    /// The shapes that must pass: the deployment invariant itself, in exec and spaced
1913    /// spellings, and the invariant's `ENTRYPOINT []` with any interior whitespace. The
1914    /// derived Dockerfile passes its own guard, which is what keeps the default path
1915    /// unaffected.
1916    #[test]
1917    fn the_deployment_invariant_passes_the_daemon_cmd_guard() {
1918        require_daemon_cmd(&default_dockerfile(
1919            9000,
1920            Some("/work"),
1921            &BaseImage::al2023(),
1922            None,
1923        ))
1924        .expect("the derived Dockerfile is the invariant");
1925        require_daemon_cmd("FROM x\nENTRYPOINT []\nCMD [\"/agentd\"]\n").expect("the invariant");
1926        require_daemon_cmd("FROM x\nENTRYPOINT [ ]\nCMD [\"/agentd\"]\n")
1927            .expect("interior whitespace is still the empty exec form");
1928        require_daemon_cmd("FROM x\ncmd [\"/agentd\"]\n").expect("case-insensitive, no ENTRYPOINT");
1929        // Last instruction wins, as at build time: a later empty ENTRYPOINT un-swallows.
1930        require_daemon_cmd("FROM x\nENTRYPOINT [\"/bin/sh\"]\nENTRYPOINT []\nCMD [\"/agentd\"]\n")
1931            .expect("the last ENTRYPOINT is the one the build uses");
1932        // And the reverse ordering is refused for the same reason.
1933        require_daemon_cmd("FROM x\nENTRYPOINT []\nENTRYPOINT [\"/bin/sh\"]\nCMD [\"/agentd\"]\n")
1934            .expect_err("the last ENTRYPOINT is non-empty");
1935    }
1936
1937    /// Workdir inheritance is refused when neither the base nor the Dockerfile declares
1938    /// one, and the message says where the symptom would have appeared — in the guest, a
1939    /// build cycle later.
1940    #[test]
1941    fn inheriting_a_workdir_nothing_declares_is_refused() {
1942        let base = BaseImage::al2023();
1943        assert!(base.working_dir.is_empty(), "the measured case");
1944
1945        let error = require_workdir(&base, None).expect_err("nothing declares a workdir");
1946        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1947        let message = error.to_string();
1948        assert!(message.contains("no WorkingDir"), "{message}");
1949        assert!(message.contains("nothing to inherit"), "{message}");
1950        assert!(message.contains("docs/PLATFORM.md"), "{message}");
1951    }
1952
1953    /// It is accepted when *either* side declares one — the Dockerfile's `WORKDIR` or the
1954    /// base image's own `working_dir`. Both directions, because a check that only read the
1955    /// Dockerfile would refuse a caller on a purpose-built image that declares one.
1956    #[test]
1957    fn either_the_dockerfile_or_the_base_may_supply_the_workdir() {
1958        let base = BaseImage::al2023();
1959        require_workdir(&base, Some("FROM x\nWORKDIR /srv\n"))
1960            .expect("the Dockerfile declares one");
1961
1962        let purpose_built = BaseImage {
1963            working_dir: "/app".to_string(),
1964            ..BaseImage::al2023()
1965        };
1966        require_workdir(&purpose_built, None).expect("the base image declares one");
1967    }
1968
1969    /// A bare `WORKDIR` with no argument does not count as declaring one. It is the shape a
1970    /// truncated edit leaves behind, and treating it as a declaration would pass the guard
1971    /// on a Dockerfile that sets nothing.
1972    #[test]
1973    fn a_workdir_with_no_argument_does_not_count_as_a_declaration() {
1974        let error = require_workdir(&BaseImage::al2023(), Some("FROM x\nWORKDIR\n"))
1975            .expect_err("WORKDIR with no path declares nothing");
1976        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1977    }
1978
1979    /// **IMAGE-1, the acceptance test #220 names.** Wrapping a bare `FROM` produces exactly the
1980    /// default Dockerfile for that base, so the stanza has one source: the default generator's
1981    /// output minus its `FROM` line is what a wrap appends. Checked with and without a workdir,
1982    /// and on a non-default port, because each of those is a branch of the one stanza.
1983    #[test]
1984    fn wrapping_a_bare_from_is_the_default_dockerfile_minus_nothing() {
1985        for (port, workdir) in [
1986            (DEFAULT_AGENT_PORT_FOR_TESTS, None),
1987            (DEFAULT_AGENT_PORT_FOR_TESTS, Some("/srv/task")),
1988            (8080, None),
1989        ] {
1990            let base = BaseImage {
1991                docker_ref: "x".to_string(),
1992                ..BaseImage::al2023()
1993            };
1994            let default = default_dockerfile(port, workdir, &base, None);
1995            let opts = WrapOptions {
1996                port,
1997                workdir: workdir.map(str::to_string),
1998                ..WrapOptions::default()
1999            };
2000            assert_eq!(
2001                wrap_dockerfile("FROM x\n", &opts).expect("a bare FROM wraps"),
2002                default,
2003                "IMAGE-1: port {port} workdir {workdir:?}"
2004            );
2005            let (from, stanza) = default.split_once('\n').expect("lines");
2006            assert_eq!(from, "FROM x");
2007            assert!(stanza.ends_with("CMD [\"/agentd\"]\n"), "{stanza}");
2008        }
2009    }
2010
2011    /// **IMAGE-2.** Whatever the task set — its own entrypoint, command, port, user — the
2012    /// wrapped result ends on the bootstrap invariant with the client's port, and a task that
2013    /// ends on another user has `USER root` restored ahead of the stanza. A task that ends on
2014    /// root, or never changes user, gets no extra line, which is what keeps IMAGE-1's equality.
2015    #[test]
2016    fn a_wrapped_task_ends_on_the_invariant_with_root_restored_when_needed() {
2017        let task = "FROM python:3.12-slim\nWORKDIR /app\nENV AGENTD_PORT=8080\n\
2018                    ENTRYPOINT [\"/bin/sh\", \"-c\"]\nCMD [\"python\", \"serve.py\"]\nUSER app\n";
2019        let wrapped = wrap_dockerfile(task, &WrapOptions::default()).expect("wraps");
2020        let added = wrapped
2021            .strip_prefix(task)
2022            .expect("the task text is kept verbatim");
2023        assert!(
2024            added.starts_with("USER root\nCOPY agentd /agentd\n"),
2025            "{added}"
2026        );
2027        assert_eq!(dockerfile_entrypoint(&wrapped), Some("[]"));
2028        assert_eq!(dockerfile_cmd(&wrapped), Some(r#"["/agentd"]"#));
2029        assert_eq!(dockerfile_agentd_port(&wrapped), Some(9000));
2030        require_daemon_cmd(&wrapped).expect("the daemon is the CMD");
2031        require_matching_agentd_port(9000, &wrapped).expect("the stanza's port wins");
2032
2033        for (last_user, restored) in [
2034            ("USER root", false),
2035            ("USER 0", false),
2036            ("USER root:root", false),
2037            ("USER 0:0", false),
2038            ("user app", true),
2039            ("USER 1000:1000", true),
2040            ("USER root:staff", true),
2041            ("USER ${TASK_USER}", true),
2042        ] {
2043            let task = format!("FROM x\n{last_user}\n");
2044            let added = wrap_dockerfile(&task, &WrapOptions::default())
2045                .expect("wraps")
2046                .strip_prefix(task.as_str())
2047                .expect("verbatim")
2048                .to_string();
2049            assert_eq!(
2050                added.starts_with("USER root\n"),
2051                restored,
2052                "IMAGE-2: {last_user:?}: {added}"
2053            );
2054        }
2055
2056        // Only the last USER counts, as at build time.
2057        let task = "FROM x\nUSER app\nUSER root\n";
2058        let wrapped = wrap_dockerfile(task, &WrapOptions::default()).expect("wraps");
2059        let added = wrapped.strip_prefix(task).expect("verbatim");
2060        assert!(added.starts_with("COPY agentd /agentd\n"), "{added}");
2061    }
2062
2063    /// **IMAGE-2, the newline.** A task file without a trailing newline gets one before the
2064    /// stanza, in either line ending, so the stanza's first line is its own instruction.
2065    #[test]
2066    fn a_task_without_a_trailing_newline_is_normalized() {
2067        let wrapped = wrap_dockerfile("FROM x", &WrapOptions::default()).expect("wraps");
2068        assert!(
2069            wrapped.starts_with("FROM x\nCOPY agentd /agentd\n"),
2070            "{wrapped}"
2071        );
2072        let wrapped =
2073            wrap_dockerfile("FROM x\r\nRUN true", &WrapOptions::default()).expect("wraps");
2074        assert!(
2075            wrapped.starts_with("FROM x\r\nRUN true\nCOPY agentd /agentd\n"),
2076            "{wrapped}"
2077        );
2078        let wrapped = wrap_dockerfile("FROM x\r\n", &WrapOptions::default()).expect("wraps");
2079        assert!(wrapped.starts_with("FROM x\r\nCOPY agentd"), "{wrapped}");
2080    }
2081
2082    /// **IMAGE-3.** Each refusal on its own, against an otherwise wrappable task, names its
2083    /// cause: no `FROM`, a trailing continuation (in the default and a custom escape
2084    /// character, across blank and comment lines, in either line ending), an open heredoc, a
2085    /// keepalive the client cannot tolerate, a bad workdir or port, and inheriting a workdir
2086    /// nothing declares.
2087    #[test]
2088    fn a_task_the_stanza_cannot_follow_is_refused_naming_the_cause() {
2089        let refused = |task: &str, opts: WrapOptions, cause: &str| {
2090            let error = wrap_dockerfile(task, &opts).expect_err(cause);
2091            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
2092            let message = error.to_string();
2093            assert!(message.contains(cause), "IMAGE-3 {cause:?}: {message}");
2094        };
2095        let none = WrapOptions::default;
2096        refused("RUN echo hello\n", none(), "no FROM");
2097        refused("", none(), "no FROM");
2098        refused("FROM\n", none(), "no FROM");
2099        refused(
2100            "FROM x\nRUN apt-get update && \\\n",
2101            none(),
2102            "line continuation",
2103        );
2104        refused("FROM x\nRUN a \\   \n", none(), "line continuation");
2105        refused("FROM x\nRUN a \\\r\n", none(), "line continuation");
2106        refused(
2107            "FROM x\nRUN a \\\n\n# comment\n",
2108            none(),
2109            "line continuation",
2110        );
2111        refused("# escape=`\nFROM x\nRUN a `\n", none(), "line continuation");
2112        refused("FROM x\nRUN <<EOF\necho never closed\n", none(), "heredoc");
2113        refused("FROM x\nRUN <<-\"END\"\necho x\n", none(), "heredoc");
2114        refused("FROM x\nCOPY <<A <<B /dst/\na\nA\nb\n", none(), "heredoc");
2115        refused(
2116            "FROM x\nENV AGENTD_SSE_KEEPALIVE_SECS=90\n",
2117            none(),
2118            "AGENTD_SSE_KEEPALIVE_SECS=90",
2119        );
2120        for workdir in ["relative/dir", "/srv\nRUN evil", "/my dir", "/tab\there"] {
2121            refused(
2122                "FROM x\n",
2123                WrapOptions {
2124                    workdir: Some(workdir.to_string()),
2125                    ..none()
2126                },
2127                "absolute path",
2128            );
2129        }
2130        refused("FROM x\n", WrapOptions { port: 0, ..none() }, "port");
2131        refused(
2132            "FROM x\n",
2133            WrapOptions {
2134                inherit_workdir: true,
2135                ..none()
2136            },
2137            "nothing to inherit",
2138        );
2139    }
2140
2141    /// **IMAGE-3, the other side.** What looks like a refusal but is not: a backslash under a
2142    /// backtick escape, a continuation that was finished, a closed heredoc (including a `<<-`
2143    /// terminator indented with tabs), a here-string, an escaped keepalive under the timeout,
2144    /// and inheriting a workdir the task or the options declare.
2145    #[test]
2146    fn a_finished_task_is_wrapped_even_when_it_looks_unfinished() {
2147        let none = WrapOptions::default;
2148        for task in [
2149            "# escape=`\nFROM x\nRUN a \\\n",
2150            "FROM x\nRUN a \\\n  && b\n",
2151            "FROM x\nRUN <<EOF\necho closed\nEOF\n",
2152            "FROM x\nRUN <<-EOF\n\techo closed\n\tEOF\n",
2153            "FROM x\nCOPY <<A <<B /dst/\na\nA\nb\nB\n",
2154            "FROM x\nRUN cat <<< word\n",
2155            "FROM x\n# a trailing comment \\\n",
2156            "FROM x\nENV AGENTD_SSE_KEEPALIVE_SECS=30\n",
2157        ] {
2158            wrap_dockerfile(task, &none())
2159                .unwrap_or_else(|error| panic!("{task:?} was refused: {error}"));
2160        }
2161        wrap_dockerfile(
2162            "FROM x\nWORKDIR /app\n",
2163            &WrapOptions {
2164                inherit_workdir: true,
2165                ..none()
2166            },
2167        )
2168        .expect("the task declares one");
2169        wrap_dockerfile(
2170            "FROM x\n",
2171            &WrapOptions {
2172                inherit_workdir: true,
2173                workdir: Some("/srv".to_string()),
2174                ..none()
2175            },
2176        )
2177        .expect("the options declare one");
2178        wrap_dockerfile(
2179            "FROM x\n",
2180            &WrapOptions {
2181                workdir: Some(String::new()),
2182                ..none()
2183            },
2184        )
2185        .expect("an empty workdir is none, as default_dockerfile reads it");
2186    }
2187
2188    /// **IMAGE-4.** A derived base takes its ref from the first `FROM`, keeps the managed
2189    /// base's name (so `baseImageArn` is unchanged) and an empty working dir, and pairs with
2190    /// the Dockerfile it came from under the `FROM` guard — digest pins included, since the
2191    /// ref is taken whole. A Dockerfile with no `FROM` has nothing to derive.
2192    #[test]
2193    fn a_base_derived_from_a_dockerfile_pairs_with_it_by_construction() {
2194        let digest = "c439fb4994ea7ca529233d6256446d3f8b7b4efb58956073e015303a170011de";
2195        for (task, docker_ref) in [
2196            ("FROM python:3.12-slim\n", "python:3.12-slim".to_string()),
2197            (
2198                "FROM --platform=linux/arm64 golang:1.23 AS build\nFROM python:3.12-slim\n",
2199                "golang:1.23".to_string(),
2200            ),
2201            (
2202                &*format!("FROM ubuntu:24.04@sha256:{digest}\n"),
2203                format!("ubuntu:24.04@sha256:{digest}"),
2204            ),
2205        ] {
2206            let base = BaseImage::from_dockerfile(task).expect("a FROM");
2207            assert_eq!(base.docker_ref, docker_ref);
2208            assert_eq!(
2209                base.name,
2210                BaseImage::al2023().name,
2211                "IMAGE-4 keeps the managed base"
2212            );
2213            assert_eq!(base.working_dir, "");
2214            let wrapped = wrap_dockerfile(task, &WrapOptions::default()).expect("wraps");
2215            require_matching_from(&base, &wrapped).expect("IMAGE-4: the pair agrees");
2216            assert_eq!(BaseImage::from_dockerfile(&wrapped).expect("a FROM"), base);
2217        }
2218        // The inversion #220 removes: under the managed base, a task on another base is
2219        // refused.
2220        require_matching_from(
2221            &BaseImage::al2023(),
2222            &wrap_dockerfile("FROM python:3.12-slim\n", &WrapOptions::default()).expect("wraps"),
2223        )
2224        .expect_err("the managed base does not pair with python:3.12-slim");
2225
2226        let error = BaseImage::from_dockerfile("RUN echo hello\n").expect_err("no FROM");
2227        assert_eq!(error.kind(), ErrorKind::InvalidArg);
2228        assert!(error.to_string().contains("no FROM"), "{error}");
2229    }
2230
2231    /// **The table `crates/model/src/wrap.rs` specifies**, rendered as real Dockerfile text: every
2232    /// task and option combination the model enumerates, and the refusal (or the `USER root`
2233    /// verdict) the model's `specified` gives it. The model crate cannot be a dependency here
2234    /// (this crate is published and the model isn't), so the table is restated as `expected`
2235    /// below, and `crates/model-conformance/tests/tables.rs` drives this function over the model's rows.
2236    #[test]
2237    fn wrap_agrees_with_the_model_table() {
2238        #[derive(Debug)]
2239        enum Expect {
2240            Refused(&'static str),
2241            Wrapped { user_root: bool },
2242        }
2243        let digest = "c439fb4994ea7ca529233d6256446d3f8b7b4efb58956073e015303a170011de";
2244        let managed = BaseImage::al2023().docker_ref;
2245        let froms = [
2246            None,
2247            Some(managed.clone()),
2248            Some(format!("{managed}@sha256:{digest}")),
2249            Some("python:3.12-slim".to_string()),
2250        ];
2251        let mut checked = 0;
2252        for from in &froms {
2253            for tail in 0..3 {
2254                for user in 0..3 {
2255                    for declares_workdir in [false, true] {
2256                        for keepalive_too_long in [false, true] {
2257                            for workdir in 0..3 {
2258                                for inherit_workdir in [false, true] {
2259                                    let mut text = String::new();
2260                                    match from {
2261                                        Some(from) => text.push_str(&format!("FROM {from}\n")),
2262                                        None => text.push_str("RUN echo no base\n"),
2263                                    }
2264                                    if declares_workdir {
2265                                        text.push_str("WORKDIR /app\n");
2266                                    }
2267                                    if keepalive_too_long {
2268                                        text.push_str("ENV AGENTD_SSE_KEEPALIVE_SECS=60\n");
2269                                    }
2270                                    match user {
2271                                        1 => text.push_str("USER root\n"),
2272                                        2 => text.push_str("USER app\n"),
2273                                        _ => {}
2274                                    }
2275                                    match tail {
2276                                        1 => text.push_str("RUN make \\\n"),
2277                                        2 => text.push_str("RUN <<EOF\necho open\n"),
2278                                        _ => {}
2279                                    }
2280                                    let opts = WrapOptions {
2281                                        workdir: match workdir {
2282                                            1 => Some("/srv".to_string()),
2283                                            2 => Some("srv".to_string()),
2284                                            _ => None,
2285                                        },
2286                                        inherit_workdir,
2287                                        ..WrapOptions::default()
2288                                    };
2289                                    let expected = if from.is_none() {
2290                                        Expect::Refused("no FROM")
2291                                    } else if tail != 0 {
2292                                        Expect::Refused(if tail == 1 {
2293                                            "line continuation"
2294                                        } else {
2295                                            "heredoc"
2296                                        })
2297                                    } else if keepalive_too_long {
2298                                        Expect::Refused("AGENTD_SSE_KEEPALIVE_SECS")
2299                                    } else if workdir == 2 {
2300                                        Expect::Refused("absolute path")
2301                                    } else if inherit_workdir && !declares_workdir && workdir == 0 {
2302                                        Expect::Refused("nothing to inherit")
2303                                    } else {
2304                                        Expect::Wrapped {
2305                                            user_root: user == 2,
2306                                        }
2307                                    };
2308                                    let got = wrap_dockerfile(&text, &opts);
2309                                    match (&expected, &got) {
2310                                        (Expect::Refused(cause), Err(error)) => assert!(
2311                                            error.to_string().contains(cause),
2312                                            "{expected:?} for {text:?} {opts:?}: {error}"
2313                                        ),
2314                                        (Expect::Wrapped { user_root }, Ok(out)) => assert_eq!(
2315                                            out.strip_prefix(text.as_str())
2316                                                .expect("verbatim")
2317                                                .starts_with("USER root\n"),
2318                                            *user_root,
2319                                            "{text:?} {opts:?}"
2320                                        ),
2321                                        _ => panic!(
2322                                            "{expected:?} for {text:?} {opts:?}, got {got:?}"
2323                                        ),
2324                                    }
2325                                    checked += 1;
2326                                }
2327                            }
2328                        }
2329                    }
2330                }
2331            }
2332        }
2333        assert_eq!(
2334            checked,
2335            4 * 3 * 3 * 2 * 2 * 3 * 2,
2336            "every combination the model has"
2337        );
2338    }
2339
2340    fn context_of(entries: &[(&str, u32, &[u8])]) -> crate::control::context::BuildContext {
2341        crate::control::context::BuildContext::from_entries(
2342            entries
2343                .iter()
2344                .map(
2345                    |(name, mode, bytes)| crate::control::context::ContextEntry {
2346                        name: (*name).to_string(),
2347                        mode: *mode,
2348                        bytes: bytes.to_vec(),
2349                    },
2350                )
2351                .collect(),
2352        )
2353        .expect("valid entries")
2354    }
2355
2356    /// **IMAGE-6, the pin.** A request with no build context — `None`, or an empty one — hashes
2357    /// to exactly what `artifact_content_hash` produced before #221, so no `build --reuse` or
2358    /// `AgentVm` image name moves. The vector is the one pinned for #74, recomputed
2359    /// independently with Python's hashlib then.
2360    #[test]
2361    fn a_request_without_a_context_keeps_its_digest() {
2362        let pinned = "90a0ef925a4696b0c0f142241b2c714e63f40071d0d02951e26cb2a1d8bb24fc";
2363        assert_eq!(
2364            artifact_content_hash_with_context(b"binary-bytes", "FROM scratch\n", None, None),
2365            pinned,
2366            "IMAGE-6: the no-context digest is the pre-#221 digest"
2367        );
2368        let empty = crate::control::context::BuildContext::default();
2369        assert_eq!(
2370            artifact_content_hash_with_context(
2371                b"binary-bytes",
2372                "FROM scratch\n",
2373                None,
2374                Some(&empty)
2375            ),
2376            pinned,
2377            "an empty context carries no extra entries, so it is the same artifact"
2378        );
2379        assert_eq!(
2380            artifact_content_hash(b"binary-bytes", "FROM scratch\n", None),
2381            pinned
2382        );
2383    }
2384
2385    /// **IMAGE-6, the extension.** Each entry's name, mode, and bytes are part of the
2386    /// identity; the order entries were read in is not, because a context is sorted by name.
2387    #[test]
2388    fn the_context_hash_follows_every_entry_and_not_their_order() {
2389        let hash = |context: &crate::control::context::BuildContext| {
2390            artifact_content_hash_with_context(b"bin", "FROM x\n", None, Some(context))
2391        };
2392        let base = hash(&context_of(&[
2393            ("a.txt", 0o644, b"a"),
2394            ("b.txt", 0o644, b"b"),
2395        ]));
2396        assert_ne!(
2397            base,
2398            artifact_content_hash_with_context(b"bin", "FROM x\n", None, None),
2399            "IMAGE-6: a context is part of the identity"
2400        );
2401        assert_eq!(
2402            base,
2403            hash(&context_of(&[
2404                ("b.txt", 0o644, b"b"),
2405                ("a.txt", 0o644, b"a")
2406            ])),
2407            "the read order is not"
2408        );
2409        for (changed, what) in [
2410            (
2411                context_of(&[("a.txt", 0o644, b"A"), ("b.txt", 0o644, b"b")]),
2412                "bytes",
2413            ),
2414            (
2415                context_of(&[("a.txt", 0o755, b"a"), ("b.txt", 0o644, b"b")]),
2416                "mode",
2417            ),
2418            (
2419                context_of(&[("c.txt", 0o644, b"a"), ("b.txt", 0o644, b"b")]),
2420                "name",
2421            ),
2422            (context_of(&[("a.txt", 0o644, b"ab")]), "boundary"),
2423        ] {
2424            assert_ne!(base, hash(&changed), "IMAGE-6: the entry {what} counts");
2425        }
2426    }
2427
2428    /// **IMAGE-7, the zip.** Equal inputs make byte-identical artifacts, so equal inputs are
2429    /// one S3 object: every entry carries the DOS epoch as its date and a fixed mode, the
2430    /// context entries sit beside the Dockerfile at the root, and the Dockerfile entry is the
2431    /// one passed in. The same build a second apart, which needs a real sleep this crate
2432    /// can't make, is `crates/microvms-core/tests/artifact_across_seconds.rs`.
2433    #[test]
2434    fn the_artifact_is_deterministic_with_fixed_dates_and_modes() {
2435        use std::io::Read as _;
2436
2437        let context = context_of(&[("app/run.sh", 0o755, b"#!/bin/sh\n"), ("data", 0o644, b"d")]);
2438        let build = || {
2439            build_artifact_with_context(
2440                &aarch64_daemon(b"daemon"),
2441                "FROM x\n",
2442                None,
2443                Some(&context),
2444            )
2445            .expect("zips")
2446        };
2447        let first = build();
2448        assert_eq!(first, build(), "IMAGE-7: equal inputs, identical bytes");
2449
2450        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(first)).expect("a zip");
2451        let mut names = Vec::new();
2452        for index in 0..archive.len() {
2453            let entry = archive.by_index(index).expect("entry");
2454            names.push(entry.name().to_string());
2455            let modified = entry.last_modified().expect("a date");
2456            assert_eq!(
2457                (modified.year(), modified.month(), modified.day()),
2458                (1980, 1, 1),
2459                "IMAGE-7: {} carries a fixed date",
2460                entry.name()
2461            );
2462            let mode = entry.unix_mode().expect("a mode") & 0o777;
2463            let expected = match entry.name() {
2464                "agentd" | "app/run.sh" => 0o755,
2465                _ => 0o644,
2466            };
2467            assert_eq!(mode, expected, "IMAGE-7: {} mode {mode:o}", entry.name());
2468        }
2469        assert_eq!(names, ["Dockerfile", "agentd", "app/run.sh", "data"]);
2470        let mut dockerfile = String::new();
2471        archive
2472            .by_name("Dockerfile")
2473            .expect("entry")
2474            .read_to_string(&mut dockerfile)
2475            .expect("reads");
2476        assert_eq!(dockerfile, "FROM x\n");
2477    }
2478
2479    /// **IMAGE-7, the byte scan extended.** The agent token has no path into a context either:
2480    /// the artifact built from a context is scanned the way AC-2-3 scans the plain one.
2481    #[test]
2482    fn a_context_artifact_never_carries_the_agent_token() {
2483        let token = "s3cr3t-agent-token-do-not-bake-me";
2484        let context = context_of(&[("app/main.py", 0o644, b"print('hi')\n")]);
2485        let bytes = build_artifact_with_context(
2486            &aarch64_daemon(b"binary"),
2487            &wrap_dockerfile("FROM x\n", &WrapOptions::default()).expect("wraps"),
2488            None,
2489            Some(&context),
2490        )
2491        .expect("zips");
2492        assert!(
2493            !bytes
2494                .windows(token.len())
2495                .any(|window| window == token.as_bytes())
2496        );
2497    }
2498
2499    /// The base image ARN, for the region it is requested in.
2500    ///
2501    /// `microvm-image:` with a **colon**, which is the spelling for a managed base *and* for
2502    /// a customer image alike — measured 2026-08-15 against real ARNs in us-east-1, and the
2503    /// only form the model's `TaggableResource` pattern admits. The docs above this function
2504    /// carry the measurement.
2505    ///
2506    /// **Falsification** — change the separator to `/` and both assertions go red. That was
2507    /// the state twelve fakes and the transport encoding test were in, which is why this
2508    /// test now asserts the separator explicitly rather than only the region.
2509    #[test]
2510    fn the_base_image_arn_names_the_request_region_with_a_colon_separator() {
2511        assert_eq!(
2512            BaseImage::al2023().arn(&crate::region::Region::UsEast1),
2513            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
2514        );
2515        assert_eq!(
2516            BaseImage::al2023().arn(&crate::region::Region::ApNortheast1),
2517            "arn:aws:lambda:ap-northeast-1:aws:microvm-image:al2023-1"
2518        );
2519        assert!(
2520            !BaseImage::al2023()
2521                .arn(&crate::region::Region::UsEast1)
2522                .contains("microvm-image/"),
2523            "the slash form answers AccessDeniedException, not a validation error"
2524        );
2525    }
2526}