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