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