diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 4f7adbce8..570e0d175 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -192,7 +192,7 @@ Compose backend. Native support for them is planned after v5. | External network | A top-level network sets `external: true` or `external: {name: ...}`. A reviewed `x-hack-isolated: true` replacement is not external: it creates an owned internal bridge. A network that the selected adaptation (`HACK_NATIVE_ADAPTATION` or the committed `.hack/native/adaptation.json`; see [Native settings files](#native-settings-files)) lists in `isolatedNetworks` gets the same replacement, so it is not external either. | | External volume | A top-level volume sets `external: true` or `external: {name: ...}`. The native runtime only creates volumes it owns, and there is no native replacement for an external volume. | | Reserved ownership label | A service, network or volume label key starts with `com.docker.compose.`, `io.hack-local.`, `io.hack.native-config.`, `io.hack.native-file.` or `io.hack.storage-witness.`. Compose, the native runtime and native-config Compose use these labels to prove which resources they own. | -| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. | +| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. With `HACK_NATIVE_BUILDS=1` (see below), only a build field other than `context`, `dockerfile`, `target` and `args`, or `image` together with `build`, counts; native build admission reviews the values of those four fields itself. | | Non-native platform | A service sets a `platform` other than `linux/arm64` or `linux/arm64/v8`, for example `linux/amd64`. The native VM runs Linux arm64. A missing or `null` `platform` is unset and passes. `linux/amd64` passes only for a service that the `HACK_NATIVE_ADAPTATION` selection opts in to [amd64 emulation](#amd64-emulation-with-rosetta-2); every other platform value stays on the Compose backend. | Native `hack up` and `hack restart` check for these features after selecting the @@ -252,9 +252,51 @@ routing labels (`one_shot_route_conflict`). because it reads and adopts the Compose backend's existing containers and volumes. On the native backend it refuses before reading anything and names the rerun: `HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run`. See -[Legacy Compose adoption](../reference/native-compose-adoption.md). A basic build is also -refused by native image-project `up`, with the general refusal: it needs the -authored native format. +[Legacy Compose adoption](../reference/native-compose-adoption.md). + +Native image-project `up` builds Compose `build:` services only when you opt in +with `HACK_NATIVE_BUILDS=1` (and the usual `HACK_NATIVE_SHARED_SOURCE=1`). Without +it, a `build:` service refuses before any hook or VM effect and names the variable. +With it: + +- Accepted forms are `build: ` and a mapping of `context`, `dockerfile`, + `target` and `args`. Paths follow Compose (the context is relative to the Compose + file, the Dockerfile to the context) and must stay inside the project. `args` may be + a mapping of literal strings or numbers, or a list of `KEY=VALUE` literals. Any `$` + interpolation, a list item without `=` (host variable inheritance), `null`, other + build keys (`secrets`, `ssh`, `cache_from`, `platforms`, `network` and the rest), + remote contexts and `image` together with `build` refuse. Values are never shown. +- Build args are not secrets: as with Docker, the image records them in its history. + A build arg whose value equals a `hack env` secret value of this project and + environment refuses before any hook or VM effect, naming the service and the + argument but never the value. The refusal does not suggest the Compose backend, + because a Compose build would record the value in the image history too. Pass + secrets with BuildKit build secrets instead; native builds do not support them yet. +- Every build refusal names the service. A refusal from the runtime's build source + policy (for example a base image that is not a public Docker Hub reference) says + that it is the build source policy and what to change. Other build failures name + the service and keep the runtime's message. +- After the runtime is up, each distinct build runs once with + `runtime build-image` (below), and its filtered output streams to your terminal. + Every service with that build is then pinned to the built content ID, so review, + readiness and restore treat it like an image service. +- An existing image for the same build inputs is reused without building. Set + `HACK_NATIVE_REBUILD=1` to build again; base-image tags then resolve again too. + Edits to other files in the context do not trigger a rebuild, as with Compose. +- Restoring an unchanged retained run uses the retained images and never builds; if + one is missing, `up` refuses and asks you to start fresh. `hack restart` also never + builds: it reuses the running or retained image, or refuses before stopping anything. +- "Unchanged" means the same Compose bytes. A run that mounts no project source and + uses no dependency cache still restores after its Compose file was edited and put + back, or after any project file was touched; build-context edits do not rebuild it. + Its review then differs only in the project source inventory (file sizes and + modification times), which such a run never reads, and restore keeps the retained + ownership plan, images and data. +- As with Compose `up`, only build services in the selected profiles are built and + pinned. A build service in a profile you did not select is not built, and restore + and restart need no image for it; selecting its profile in a later fresh `up` builds + it then. Native builds are `linux/arm64` only; `platform: linux/amd64` is still + refused. Native review reads Compose `ports` without reading environment values. Each entry may be short (`[host_ip:][published:]target[/protocol]`, with an IPv6 host @@ -314,12 +356,14 @@ the same adaptation selection as native `up` without `--env` before it reports. finds an external network, the message also names the `isolatedNetworks` selection above, which keeps that network's project on native. -A basic build is not one of these features, but native image-project `up` still -refuses it, so doctor does not report it as ok. On the native backend the check -warns, names the general configuration refusal and the next step (preview the -authored native conversion with `hack config import --dry-run`, or select the -Compose backend), and its JSON adds `composeBackend.nativeUpRefusals: -["basic_build"]`. On the Compose backend the same note is informational. +A basic build is not one of these features, but without `HACK_NATIVE_BUILDS=1` +native image-project `up` refuses it, so doctor does not report it as ok then. On the +native backend the check warns, names the variable and the next steps (set +`HACK_NATIVE_BUILDS=1`, preview the authored native conversion with +`hack config import --dry-run`, or select the Compose backend), and its JSON adds +`composeBackend.nativeUpRefusals: ["basic_build"]`. On the Compose backend the same +note is informational. With `HACK_NATIVE_BUILDS=1` set, doctor classifies builds the +way native builds admit them and reports no basic-build refusal. Publication control tokens are delivered to the relay through a one-shot inherited pipe (`--control-fd 0`), never through process arguments. The relay reads exactly @@ -579,6 +623,11 @@ the mapping, inspect the exact instance with the current bundle: Omitting `--branch` uses the same linked-worktree default as project commands; detached linked worktrees require an explicit instance. Inspection writes nothing. +A mapping that still matches the checkout reports `state: "current"` with its run, +the native graph phase (or `null` while the pool is stopped), whether the graph's +run, owner, namespace and plan match the mapping, and `repairRequired: false`. +Only a mapping whose scope no longer matches goes through the legacy device-rebind +inspection below, which returns the `selectionSha256` that repair requires. Repair requires the exact selection and explicit acceptance of unproven original filesystem volume continuity. It changes only the three scope directory device numbers, preserving canonical paths, inodes, branch, run, owner, plan, environment @@ -608,6 +657,79 @@ Provider setup is separate and explicit. Obtain the exact archives identified in ./hack-native --candidate-root /absolute/private/candidate-home runtime probe --json ``` +The pins also name a BuildKit client (`buildx`, `darwin-arm64`) for native image +builds. Prepare it before using `HACK_NATIVE_BUILDS=1`; it is optional otherwise: + +```sh +./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-builder --archive /absolute/docker-buildx +``` + +The command takes the exact release file named in `provider-pins.json`. It checks +the SHA-256 before it changes any state, then copies the file into private candidate +state and checks its code signature. If a prepared client exists, the command checks +it again and does not replace it. If the prepared tree changes, `runtime probe` +refuses; it reports `builder_prepared` when the client is present and unchanged. + +`runtime build-image --request --json` builds one image in the running +runtime's engine with that client. Native `up` calls it for `build:` services when +`HACK_NATIVE_BUILDS=1` is set; you can also run it directly. The request is a private +JSON file (a singly linked, mode-`0600` file owned by you): + +```json +{"version": 1, "project_root": "/absolute/project", "context": "app", + "dockerfile": "Dockerfile", "target": null, "args": {"NAME": "literal"}, + "policy": "missing", "timeout_seconds": 1800} +``` + +- The context and Dockerfile must be inside the project and reached without symlinks. + The Dockerfile is read, never run, to find every image it can pull. Each image must + be a public Docker Hub reference; it is pinned to a digest before the build starts. + Base images with stored `ONBUILD` triggers refuse. The client builds from a private + copy of the Dockerfile and ignore rules that were scanned, so editing either file + during the build has no effect on it. +- Build arguments are not secrets, exactly as with Docker: the built image records + their values in its history, and an argument that selects a base image appears in + the resolved base references that the receipt and the result report. Never pass a + secret as a build argument. BuildKit build secrets are the right mechanism for + secrets; native builds do not support them yet. +- Every build runs under a BuildKit source policy: only the pinned base images + resolve as images, and network sources (`ADD` of an `http`, `https` or git URL, and + image blob sources) are denied. The engine would otherwise fetch those from outside + the isolated build network. The policy also covers sources that a base image's + stored `ONBUILD` triggers add; such bases refuse before the build anyway. +- Argument values reach the client only through a private definition file (mode + `0600`, removed after the build), never its command line or its environment; the + client's environment is a fixed list of its own settings. Names must be plain + identifiers. Names BuildKit or buildx interpret themselves (`BUILDKIT_*`, + `BUILDX_*`, `SOURCE_DATE_EPOCH`) and the platform arguments refuse. +- Build output is shown on stderr after filtering, as defense in depth: every + argument value is replaced with `[redacted]`, also when quoted or escaped the way + the client prints values, terminal control characters are removed, lines are cut at + 16 KiB and output stops after 4 MiB. +- `missing` reuses a verified image for the same build key without running the client; + `rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes, + target, arguments and versions, not the context's other files (as with Compose). +- The runtime must have network egress and the isolated build network that a runtime + started by this version sets up. An older runtime refuses with + `native_build_runtime_restart`: stop it and start it again. +- `runtime down` refuses while a build runs. Ctrl-C or SIGTERM cancels the build and + stops the client's whole process group; so does the time budget (default 30 minutes, + at most 2 hours). Both also apply while base images resolve, before the client + starts: the budget covers the whole operation. +- A cold build needs at least 6 GiB free on the runtime's storage disk, or twice the + last image of the same build if that is larger; otherwise it refuses with + `native_build_storage` and deletes nothing. After each successful build, BuildKit's + cache is pruned to 8 GiB. The cache holds only build steps; pruning never removes + images, containers or volumes. + +`runtime reclaim-build-images --json` removes build images that a newer build of the +same key replaced. It keeps every image a build receipt names as current, every image +that any retained run's saved state mentions, every image a container still uses (the +engine refuses those), and every image whose ownership labels are not exactly this +runtime's. Each removal deletes that one image only, never its untagged parent +images. It refuses while a build runs, and removes nothing if it cannot read all +saved run state. The report lists removed images and why each other one was kept. + Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with regular, singly linked mode-`0600` files. Acquire the matching public package bytes; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ace5ba6dc..146b40069 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1431,7 +1431,7 @@ hack doctor [options] | `--browser-result unknown|works|fails|permission-denied` | Your manual browser observation for --browser-url (default: unknown) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--native-cleanup inspect` | Inspect one mapped native graph's pending cleanup without recovery or stop replay | -| `--native-run-mapping inspect|repair` | Inspect or explicitly repair a native run mapping after filesystem device renumbering | +| `--native-run-mapping inspect|repair` | Inspect a native run mapping, or explicitly repair it after filesystem device renumbering | | `--expect-selection <64-hex>` | Require the exact run-mapping recovery inspection selection | | `--accept-legacy-device-rebind` | Explicitly accept legacy migration without proof of original filesystem volume continuity | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 14d4d7c1e..3e5904470 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -50,6 +50,7 @@ must be attached to the eventual exact integration revision. | Dependency rebind, automatic refresh, incomplete initializer, terminal reuse | dependency-rebind, dependency-auto-refresh, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | | Private environment allocation lifetime: staged values and private file members, crash between record and stage, retirement with the container | **Coverage gap:** no maintained model covers environment or credential delivery; file members share the existing allocation, intent record and retirement, so the state machine is unchanged | `packages/runtime-core/src/provider/environment.rs` (preparation bounds, staging layout, verification), `managed_environment.rs` (envelope v2 refusals, forwarding), `graph/launcher.rs` (read-only binds, overlap refusals) and `environment_recovery.rs` retirement tests | Live VM evidence that only the owning UID reads a member, writes fail read-only and the member is absent after cleanup; refresh or rotation of delivered files is not implemented. | | One pool, many project roots: share-set changes, boot mounts, per-project share selection, removal of a referenced share | multi-root-share | `provider::share_update` (journaled stopped-pool add/remove, record before owner), `project_share::{check_request, check_boot, validate_set}`, `OwnedGuest::project_share_for`; `share_update` and `project_share` unit tests | Real provider boot with an updated mount record, virtio-fs device limits and live multi-project graphs on a host VM. Cross-project cleanup is abstracted to one run's own resources. | +| Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; a build client orphaned by a killed `hack-native` is not modeled (the client inherits the build fence and per-key lock, so both stay held until it exits: `image_build::tests::a_killed_coordinator_leaves_its_client_fenced`); real BuildKit, engine removal semantics and guest storage are host evidence. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | @@ -99,6 +100,7 @@ liveness/reachability controls have their own documented assumptions. | [service-restart-policy](../../tests/models/tla/service-restart-policy/RestartPolicy.tla) | 134 | 5 | | [relay-fence](../../tests/models/tla/relay-fence/RelayFence.tla) | 16 | 2 | | [pool-emulation](../../tests/models/tla/pool-emulation/Emulation.tla) | 13 | 3 | +| [native-build-retention](../../tests/models/tla/native-build-retention/BuildRetention.tla) | 6,181 | 4 | | [shared-helper-instance](../../tests/models/tla/shared-helper-instance/Instance.tla) | 7,649 | 11 | | [restore-history](../../tests/models/tla/restore-history/RestoreHistory.tla) | See named result contract | 2 | | [graph-admission](../../tests/models/tla/graph-admission/Admission.tla) | See named result contract | 2 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 649ebeffa..45d8d4b74 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -487,6 +487,25 @@ pinned package preparation, ownership, resource admission, lifecycle commands, a The default private build includes native HTTP graph probes (local `_docs/docs/plans/v5/native-http-probe-20260915.md`) and requires Zig 0.15.2 plus a host C compiler. [Provider pins](provider-pins.json) record the exact package inputs; the engine runs inside the VM. +The pinned host BuildKit client (`runtime prepare-builder`) and the pure Dockerfile +image-reference scanner (`provider/build_scan.rs`) back `runtime build-image` +(`provider/image_build.rs`), which builds one Compose-style image in the guest engine +from a private request file. Its build key, receipts, leases, the `runtime down` +build fence, cancellation and timeout rules are documented in that module. Egress +pools give dockerd a fixed default bridge (`10.213.255.0/24`, `icc=false`) that only +BuildKit `RUN` steps use; `guest-build-network.sh` isolates it from the guest, the host +gateway and link-local metadata, and the engine client refuses any container create +that does not name an owned network or `none`. Cold builds need free guest storage +(at least 6 GiB, or twice the key's last image), successful builds prune BuildKit +cache to 8 GiB, and `runtime reclaim-build-images` removes only owned, superseded, +unreferenced images (`provider/image_build/reclaim.rs`; model +`tests/models/tla/native-build-retention`). Native `up` calls the build operation +for Compose `build:` services only when `HACK_NATIVE_BUILDS=1` is set. The client reaches +the engine through a private relay (`provider/image_build/client_relay.rs`): the pinned +provider's macOS socket forwarding can loop forever when a host client closes a forwarded +connection while the guest is still sending, which saturated the guest and stopped every later +agent and engine request. The relay always drains guest bytes, forwards a client's end of +stream only as a half-close, and closes a provider connection only after the guest ended it. Three Cargo features build guest helpers with Zig: `native-http-probe`, `native-stream-relay` and `environment-launcher`. For each one, [`build.rs`](build.rs) @@ -508,6 +527,12 @@ tests; Cargo reporting a cached binary as fresh does not qualify that identity. native process/disk identity, guest protocol, and lifecycle decisions. Guest requests use a bounded connection to the existing socket; they cannot silently start or recover a VM. Failed operations retain their phase and receipts. Recovery preserves disks and labels an unclean exit explicitly. +When the guest agent stops responding (three pings over about 13 seconds start no reply), +`runtime down` and `runtime recover` stop this home's recorded provider after verifying its PID, +start time, user and executable, without guest quiescence, and record `recovered-unclean`; a +guest that answers keeps the normal clean stop, and `recover` still refuses it. Both refuse with +`provider_busy` while a native image build holds its fence; finishing or canceling that build +(Ctrl-C its `hack up`) releases the fence, also when a build process was killed. SmolVM seeds a machine's disks during its first start (`machine create` makes none) from expanded `*-template.ext4` files that it creates from the verified compressed templates and then reuses. diff --git a/packages/runtime-core/provider-pins.json b/packages/runtime-core/provider-pins.json index 59d34ed86..c8a964b69 100644 --- a/packages/runtime-core/provider-pins.json +++ b/packages/runtime-core/provider-pins.json @@ -12,5 +12,11 @@ "platform": "linux-arm64", "url": "https://download.docker.com/linux/static/stable/aarch64/docker-29.5.2.tgz", "archive_sha256": "d1d9cb857c32c596ea96a9ca6b25d13621a97c83511e667868a33a320b2a707f" + }, + "buildx": { + "version": "0.34.1", + "platform": "darwin-arm64", + "url": "https://github.com/docker/buildx/releases/download/v0.34.1/buildx-v0.34.1.darwin-arm64", + "archive_sha256": "e5040acdaac1a349de84c0e7a80c861a368e0d141bf7260e1fd9a74b16749477" } } diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 1d225ffdf..0f3de3e59 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -97,6 +97,7 @@ Usage: hack-local graph serve|run ... --shared-source hack-local runtime prepare --archive hack-local runtime prepare-engine --archive + hack-local runtime prepare-builder --archive hack-local runtime prepare-network-tools --directory hack-local runtime up --profile research|development --prepared-base prefer|require [--prepared-base-store ] [--json] hack-local runtime prepared-base build --profile research|development [--base-id ] [--store ] [--json] @@ -104,6 +105,8 @@ Usage: hack-local runtime prepared-base status --profile research|development [--store ] [--json] hack-local runtime prepared-base remove --base-id [--store ] [--json] hack-local runtime admit-image --reference <[registry/]repository[:tag][@sha256:digest]|sha256:image-id> --json [IMAGE-POLICY] + hack-local runtime build-image --request --json + hack-local runtime reclaim-build-images --json hack-local runtime ensure-image --reference <[registry/]repository[:tag]|[registry/]repository@sha256:digest> [--emulation native|prefer-native|amd64] --json [IMAGE-POLICY] hack-local runtime up ... --amd64-emulation [--json] hack-local runtime resolve-image --reference <[registry/]repository[:tag]> --json [IMAGE-POLICY] @@ -968,6 +971,12 @@ fn run() -> Result<(), CandidateError> { Path::new(archive), )?)?; } + ["runtime", "prepare-builder", "--archive", archive] => { + print_json(&hack_runtime_core::provider::prepare_builder( + &discover_candidate(&requested)?, + Path::new(archive), + )?)?; + } [ "runtime", "ensure-image", @@ -1007,6 +1016,20 @@ fn run() -> Result<(), CandidateError> { emulation, )?)?; } + ["runtime", "build-image", "--request", request, "--json"] => { + // Build output streams to stderr; only the result is printed on stdout. + print_json(&hack_runtime_core::provider::image_build::build_image( + &discover_candidate(&requested)?, + Path::new(request), + )?)?; + } + ["runtime", "reclaim-build-images", "--json"] => { + print_json( + &hack_runtime_core::provider::image_build::reclaim::reclaim_build_images( + &discover_candidate(&requested)?, + )?, + )?; + } [ "runtime", "admit-image", diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index f155f7a73..26fbd026f 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -24,7 +24,6 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub use enrollment::{EnrollmentReceipt, enroll, status, status_with_branch}; -#[cfg(feature = "native-config-plan")] pub(crate) use source::resolve as resolve_source; pub use source::{SourceEntry, SourceSelection}; diff --git a/packages/runtime-core/src/provider/agent.rs b/packages/runtime-core/src/provider/agent.rs index 827353614..3f9ca4c60 100644 --- a/packages/runtime-core/src/provider/agent.rs +++ b/packages/runtime-core/src/provider/agent.rs @@ -112,6 +112,59 @@ fn exchange( serde_json::from_slice(&body).map_err(|_| failure("Malformed guest response.")) } +/// Whether the agent at `path` starts any reply to a ping within `timeout`. No +/// connection, a connection that ends before a reply, and no reply by the deadline are +/// silence; any reply byte, even of an unexpected response, is an answer. Only a stop of +/// a guest that stopped responding relies on this. +pub(super) fn answers_ping(path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let Ok(bytes) = encode(&json!({"method":"ping"})) else { + return false; + }; + let Ok(mut stream) = UnixStream::connect(path) else { + return false; + }; + let mut frame = (bytes.len() as u32).to_be_bytes().to_vec(); + frame.extend_from_slice(&bytes); + if stream.set_nonblocking(true).is_err() { + return false; + } + let mut written = 0; + let mut reply = [0_u8; 1]; + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if written < frame.len() { + match stream.write(&frame[written..]) { + Ok(count) => written += count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return false, + } + } + match stream.read(&mut reply) { + Ok(0) => return false, + Ok(_) => return true, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return false, + } + let mut descriptor = libc::pollfd { + fd: stream.as_raw_fd(), + events: if written < frame.len() { + libc::POLLIN | libc::POLLOUT + } else { + libc::POLLIN + }, + revents: 0, + }; + let milliseconds = (remaining.as_millis() + 1).min(i32::MAX as u128) as i32; + // SAFETY: the descriptor is borrowed from the live stream for this synchronous wait. + unsafe { libc::poll(&mut descriptor, 1, milliseconds) }; + } +} + pub fn ping(path: &Path) -> Result<(), CandidateError> { let response = request(path, json!({"method":"ping"}), Duration::from_secs(3))?; if response["status"] != "pong" || response["version"] != 1 { diff --git a/packages/runtime-core/src/provider/artifact.rs b/packages/runtime-core/src/provider/artifact.rs index 771d3eddf..b68405f09 100644 --- a/packages/runtime-core/src/provider/artifact.rs +++ b/packages/runtime-core/src/provider/artifact.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::Read; -use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -261,6 +261,156 @@ pub fn verify_engine(candidate: &Candidate) -> Result Ok(artifact) } +/// Pinned standalone BuildKit client for native image builds. +/// +/// The release asset is a single Developer ID signed `darwin-arm64` executable that runs +/// on the host against the guest engine's forwarded private socket. Its bytes are pinned +/// by SHA-256 in `provider-pins.json`; preparation copies it into owned private state, so +/// the caller's file is never executed, and every later use re-verifies the prepared tree. +pub const BUILDER_SHA256: &str = "e5040acdaac1a349de84c0e7a80c861a368e0d141bf7260e1fd9a74b16749477"; +pub const BUILDER_VERSION: &str = "0.34.1"; +const BUILDER: BinaryPin = BinaryPin { + label: "BuildKit client", + version: BUILDER_VERSION, + sha256: BUILDER_SHA256, + directory: "buildx-0.34.1-darwin-arm64", + executable: "docker-buildx", + record: "buildx.json", + staging: "buildx-installing", + verify_signature: true, +}; + +/// One pinned single-file provider executable and where its prepared tree lives. +struct BinaryPin { + label: &'static str, + version: &'static str, + sha256: &'static str, + directory: &'static str, + executable: &'static str, + record: &'static str, + staging: &'static str, + verify_signature: bool, +} + +fn binary_root(candidate: &Candidate, pin: &BinaryPin) -> PathBuf { + candidate.state_root.join("providers").join(pin.directory) +} + +/// Prepare the pinned BuildKit client from a caller-supplied copy of its release asset. +/// +/// No state is created unless the file matches the pin. An existing prepared tree is +/// re-verified and reused, never overwritten. An interrupted preparation leaves its +/// private staging directory in place and refuses until it is inspected. +pub fn prepare_builder(candidate: &Candidate, binary: &Path) -> Result { + if !cfg!(all(target_os = "macos", target_arch = "aarch64")) { + return Err(CandidateError::new( + "unsupported_host", + "This pinned artifact requires Apple Silicon macOS.", + )); + } + prepare_binary(candidate, binary, &BUILDER) +} + +/// The prepared BuildKit client executable. Callers run it only after `verify_builder`. +pub fn builder_executable(candidate: &Candidate) -> PathBuf { + binary_root(candidate, &BUILDER).join(BUILDER.executable) +} + +/// Verify the prepared BuildKit client before any use. +pub fn verify_builder(candidate: &Candidate) -> Result { + verify_binary(candidate, &BUILDER) +} + +fn binary_mismatch(pin: &BinaryPin, detail: &str) -> CandidateError { + CandidateError::new( + "artifact_digest_mismatch", + format!("{} {detail}", pin.label), + ) +} + +fn prepare_binary( + candidate: &Candidate, + source: &Path, + pin: &BinaryPin, +) -> Result { + // A FIFO or device would block or stream; only a regular file is hashed. + if !fs::metadata(source).map_err(io)?.is_file() || digest(source)? != pin.sha256 { + return Err(binary_mismatch( + pin, + "file does not match the pinned SHA-256.", + )); + } + let _lock = state::Lock::acquire(&candidate.state_root.join("run/smolvm"))?; + let providers = candidate.state_root.join("providers"); + state::private_directory(&providers)?; + if binary_root(candidate, pin).try_exists().map_err(io)? { + return verify_binary(candidate, pin); + } + let staging = providers.join(pin.staging); + state::private_directory(&staging)?; + let prepared = staging.join(pin.directory); + // Non-recursive and exclusive: a leftover from an interrupted run is retained, not reused. + fs::DirBuilder::new() + .mode(0o700) + .create(&prepared) + .map_err(io)?; + let owned = prepared.join(pin.executable); + let mut input = File::open(source).map_err(io)?; + let mut output = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o700) + .open(&owned) + .map_err(io)?; + std::io::copy(&mut input, &mut output).map_err(io)?; + output.sync_all().map_err(io)?; + drop(output); + // Rehash the owned copy to close source replacement between the check and the copy. + if digest(&owned)? != pin.sha256 { + return Err(binary_mismatch( + pin, + "file changed during preparation; staging retained.", + )); + } + fs::set_permissions(&owned, fs::Permissions::from_mode(0o700)).map_err(io)?; + if pin.verify_signature { + run( + clean_command(Path::new("/usr/bin/codesign")) + .args(["--verify", "--strict"]) + .arg(&owned), + Duration::from_secs(10), + )?; + } + let artifact = Artifact { + version: pin.version.into(), + archive_sha256: pin.sha256.into(), + tree_sha256: tree_digest(&prepared)?, + }; + fs::rename(&prepared, binary_root(candidate, pin)).map_err(io)?; + state::write(&providers.join(pin.record), &artifact)?; + // Only the now-empty staging directory; never recursive. + let _ = fs::remove_dir(&staging); + Ok(artifact) +} + +fn verify_binary(candidate: &Candidate, pin: &BinaryPin) -> Result { + let root = binary_root(candidate, pin); + reject_aliased_state(&root)?; + let artifact: Artifact = state::read(&candidate.state_root.join("providers").join(pin.record))?; + let executable = fs::symlink_metadata(root.join(pin.executable)).map_err(io)?; + if artifact.version != pin.version + || artifact.archive_sha256 != pin.sha256 + || !executable.is_file() + || artifact.tree_sha256 != tree_digest(&root)? + { + return Err(binary_mismatch( + pin, + "prepared tree changed; refusing execution.", + )); + } + Ok(artifact) +} + #[cfg(test)] mod tests { use super::*; @@ -286,6 +436,171 @@ mod tests { assert_eq!(pins["smolvm"]["archive_sha256"], ARCHIVE_SHA256); assert_eq!(pins["docker"]["version"], ENGINE_VERSION); assert_eq!(pins["docker"]["archive_sha256"], ENGINE_SHA256); + const PLATFORM: &str = "darwin-arm64"; + assert_eq!(pins["buildx"]["version"], BUILDER_VERSION); + assert_eq!(pins["buildx"]["platform"], PLATFORM); + assert_eq!(pins["buildx"]["archive_sha256"], BUILDER_SHA256); + assert_eq!( + pins["buildx"]["url"], + format!( + "https://github.com/docker/buildx/releases/download/v{BUILDER_VERSION}/buildx-v{BUILDER_VERSION}.{PLATFORM}" + ) + ); + assert_eq!( + BUILDER.directory, + format!("buildx-{BUILDER_VERSION}-{PLATFORM}") + ); + assert_eq!(BUILDER.version, BUILDER_VERSION); + assert_eq!(BUILDER.sha256, BUILDER_SHA256); + } + fn test_pin(bytes: &[u8]) -> BinaryPin { + let sha256: &'static str = + Box::leak(format!("{:x}", Sha256::digest(bytes)).into_boxed_str()); + BinaryPin { + label: "Test client", + version: "1.2.3", + sha256, + directory: "test-client-1.2.3", + executable: "test-client", + record: "test-client.json", + staging: "test-client-installing", + verify_signature: false, + } + } + #[test] + fn a_builder_binary_that_misses_the_pin_creates_no_state() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let untrusted = root.join("untrusted-client"); + fs::write(&untrusted, b"not the pinned client").unwrap(); + let error = prepare_binary(&candidate, &untrusted, &BUILDER).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + let directory = root.join("not-a-file"); + fs::create_dir(&directory).unwrap(); + let error = prepare_binary(&candidate, &directory, &BUILDER).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + assert!(!candidate.state_root.exists()); + assert!(verify_builder(&candidate).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn a_prepared_pinned_binary_is_reused_and_any_tree_change_refuses_execution() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"#!/bin/sh\nexit 0\n"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + let prepared = prepare_binary(&candidate, &source, &pin).unwrap(); + assert_eq!(prepared.version, "1.2.3"); + assert_eq!(prepared.archive_sha256, pin.sha256); + let executable = binary_root(&candidate, &pin).join(pin.executable); + let metadata = fs::symlink_metadata(&executable).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o700); + assert_eq!(fs::read(&executable).unwrap(), bytes); + // The caller's file is copied, never moved or linked. + assert_eq!(fs::read(&source).unwrap(), bytes); + assert_ne!(metadata.ino(), fs::metadata(&source).unwrap().ino()); + assert!( + !candidate + .state_root + .join("providers") + .join(pin.staging) + .exists() + ); + // Preparing again verifies and reuses the existing tree. + let again = prepare_binary(&candidate, &source, &pin).unwrap(); + assert_eq!(again.tree_sha256, prepared.tree_sha256); + assert_eq!( + verify_binary(&candidate, &pin).unwrap().tree_sha256, + prepared.tree_sha256 + ); + + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let error = verify_binary(&candidate, &pin).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + fs::write(&executable, b"#!/bin/sh\nexit 1\n").unwrap(); + assert_eq!( + verify_binary(&candidate, &pin).unwrap_err().code, + "artifact_digest_mismatch" + ); + // A changed tree is never repaired by a later prepare. + assert_eq!( + prepare_binary(&candidate, &source, &pin).unwrap_err().code, + "artifact_digest_mismatch" + ); + fs::write(&executable, bytes).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + let extra = binary_root(&candidate, &pin).join("plugin"); + fs::write(&extra, b"extra").unwrap(); + assert!(verify_binary(&candidate, &pin).is_err()); + fs::remove_file(&extra).unwrap(); + + let other_version = BinaryPin { + version: "1.2.4", + ..test_pin(bytes) + }; + assert_eq!( + verify_binary(&candidate, &other_version).unwrap_err().code, + "artifact_digest_mismatch" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn a_replaced_prepared_executable_or_aliased_root_refuses() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"pinned client"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + prepare_binary(&candidate, &source, &pin).unwrap(); + let prepared = binary_root(&candidate, &pin); + let executable = prepared.join(pin.executable); + fs::remove_file(&executable).unwrap(); + std::os::unix::fs::symlink(&source, &executable).unwrap(); + assert!(verify_binary(&candidate, &pin).is_err()); + fs::remove_file(&executable).unwrap(); + fs::write(&executable, bytes).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + let moved = root.join("moved"); + fs::rename(&prepared, &moved).unwrap(); + std::os::unix::fs::symlink(&moved, &prepared).unwrap(); + assert_eq!( + verify_binary(&candidate, &pin).unwrap_err().code, + "aliased_state" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn an_interrupted_preparation_is_retained_and_refused() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"pinned client"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + let leftover = candidate + .state_root + .join("providers") + .join(pin.staging) + .join(pin.directory); + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&leftover) + .unwrap(); + fs::write(leftover.join(pin.executable), b"partial").unwrap(); + assert!(prepare_binary(&candidate, &source, &pin).is_err()); + assert_eq!(fs::read(leftover.join(pin.executable)).unwrap(), b"partial"); + assert!(!binary_root(&candidate, &pin).exists()); + fs::remove_dir_all(root).unwrap(); } #[test] fn unexpected_large_sparse_files_are_rejected_before_hashing_gigabytes() { diff --git a/packages/runtime-core/src/provider/build_policy.rs b/packages/runtime-core/src/provider/build_policy.rs new file mode 100644 index 000000000..e2a37aa7f --- /dev/null +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -0,0 +1,224 @@ +//! BuildKit source policy for native image builds. +//! +//! The engine fetches `ADD` URLs, git sources and image blobs from the guest's own +//! network namespace, outside the build network's isolation, and pulls any image a +//! build names. The Dockerfile scanner refuses remote sources in the Dockerfile +//! itself, but it cannot see `ONBUILD` triggers stored in a pinned base image's +//! configuration, which may add remote sources or name other images. +//! +//! [`document`] is the policy meant to close that gap independent of parsing: the +//! solver checks every source operation of a build against it, including ones a +//! base image's triggers add, and fails the build on a denied source. **Nothing +//! applies it yet.** Until the build step writes it to a private file and passes +//! that path in `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` +//! client (which reads it for `build` and `bake`), `ONBUILD` triggers in base +//! images are not enforced. +//! +//! The document is the JSON form of BuildKit's `sourcepolicy` `Policy` message, with +//! string enum names. A rule matches when the source identifier equals its selector +//! identifier, or, for `WILDCARD`, matches it with `*` standing for any text. The +//! last matching rule decides, and a source no rule matches is allowed. Rules, in +//! order: +//! +//! 1. deny every `docker-image://` and `oci-layout://` source; +//! 2. allow exactly each pinned base image, as the identifier BuildKit gives a +//! `docker-image://` named context: `docker-image://` and the +//! normalized `docker.io//[:tag]@sha256:`; +//! 3. deny every `http`, `https`, `git` and `docker-image+blob` source. +//! +//! `local://` sources (the build context and Dockerfile) match no rule and resolve. + +use super::image_reference::{ImageReference, normalize}; +use crate::CandidateError; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +/// Image source schemes denied unless the exact pinned identifier is allowed. +pub const IMAGE_SCHEMES: [&str; 2] = ["docker-image", "oci-layout"]; +/// Source schemes that fetch over the network, always denied. +pub const NETWORK_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; + +fn rule(action: &str, identifier: String, match_type: &str) -> Value { + json!({ + "action": action, + "selector": { "identifier": identifier, "match_type": match_type }, + }) +} + +/// The source policy a native build runs with, allowing only `pinned` images. +/// +/// Each entry is a base image reference the build passes as a +/// `docker-image://` named context. It must be a public Docker Hub +/// reference pinned by digest; it is normalized the way BuildKit's `llb.Image` +/// normalizes it, so the allow rule equals the source identifier exactly. +pub fn document<'a>(pinned: impl IntoIterator) -> Result { + let mut allowed = BTreeSet::new(); + for reference in pinned { + match normalize(reference) { + Ok(ImageReference::Named(named)) + if named.digest.is_some() && named.docker_hub_repository().is_some() => + { + allowed.insert(format!("docker-image://{}", named.canonical())); + } + _ => { + return Err(CandidateError::new( + "native_build_base_image", + "A build source policy allows only digest-pinned public Docker Hub images.", + )); + } + } + } + let mut rules: Vec = IMAGE_SCHEMES + .iter() + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")) + .collect(); + rules.extend( + allowed + .into_iter() + .map(|identifier| rule("ALLOW", identifier, "EXACT")), + ); + rules.extend( + NETWORK_SCHEMES + .iter() + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")), + ); + Ok(json!({ "version": 1, "rules": rules })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// BuildKit's `Wildcard2Regexp`: `^...$` with each `*` matching any text. + fn wildcard(pattern: &str, value: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + let (first, last) = (parts[0], parts[parts.len() - 1]); + if parts.len() == 1 { + return pattern == value; + } + if !value.starts_with(first) || value.len() < first.len() + last.len() { + return false; + } + let mut rest = &value[first.len()..value.len() - last.len()]; + if !value.ends_with(last) { + return false; + } + for middle in &parts[1..parts.len() - 1] { + match rest.find(middle) { + Some(at) => rest = &rest[at + middle.len()..], + None => return false, + } + } + true + } + + /// BuildKit's `evaluatePolicy` for ALLOW/DENY rules: the last match decides. + fn allowed(policy: &Value, identifier: &str) -> bool { + let mut deny = false; + for rule in policy["rules"].as_array().unwrap() { + let selector = &rule["selector"]; + let pattern = selector["identifier"].as_str().unwrap(); + let matched = pattern == identifier + || (selector["match_type"] == "WILDCARD" && wildcard(pattern, identifier)); + if matched { + deny = rule["action"] == "DENY"; + } + } + !deny + } + + #[test] + fn only_the_pinned_images_and_local_sources_are_allowed() { + let alpine = format!("docker.io/library/alpine@{A}"); + let tagged = format!("node:24-slim@{B}"); + let policy = document([alpine.as_str(), tagged.as_str()]).unwrap(); + assert_eq!(policy["version"], 1); + for identifier in [ + format!("docker-image://docker.io/library/alpine@{A}"), + format!("docker-image://docker.io/library/node:24-slim@{B}"), + "local://context".to_string(), + "local://dockerfile".to_string(), + ] { + assert!(allowed(&policy, &identifier), "{identifier}"); + } + for identifier in [ + // Unpinned, another digest, another repository with the same digest. + "docker-image://docker.io/library/alpine:3.22".to_string(), + "docker-image://docker.io/library/alpine:latest".to_string(), + format!("docker-image://docker.io/library/alpine@{B}"), + format!("docker-image://docker.io/library/busybox@{A}"), + format!("docker-image://docker.io/library/node@{B}"), + format!("docker-image://ghcr.io/example/app@{A}"), + format!("oci-layout://store/alpine@{A}"), + "https://example.com/a".to_string(), + "http://example.com/a".to_string(), + "git://github.com/org/repo.git".to_string(), + format!("docker-image+blob://docker.io/library/alpine@{A}"), + ] { + assert!(!allowed(&policy, &identifier), "{identifier}"); + } + } + + #[test] + fn the_rule_list_has_the_documented_order_and_shape() { + let short = format!("alpine@{A}"); + let policy = document([short.as_str(), short.as_str()]).unwrap(); + let rules: Vec<(String, String, String)> = policy["rules"] + .as_array() + .unwrap() + .iter() + .map(|rule| { + // Only the fields BuildKit's `Rule`/`Selector` JSON tags define. + assert_eq!(rule.as_object().unwrap().len(), 2); + assert_eq!(rule["selector"].as_object().unwrap().len(), 2); + ( + rule["action"].as_str().unwrap().to_string(), + rule["selector"]["identifier"].as_str().unwrap().to_string(), + rule["selector"]["match_type"].as_str().unwrap().to_string(), + ) + }) + .collect(); + let expected: Vec<(String, String, String)> = [ + ("DENY", "docker-image://*".to_string(), "WILDCARD"), + ("DENY", "oci-layout://*".to_string(), "WILDCARD"), + // Normalized like BuildKit's `llb.Image`, and listed once. + ( + "ALLOW", + format!("docker-image://docker.io/library/alpine@{A}"), + "EXACT", + ), + ("DENY", "http://*".to_string(), "WILDCARD"), + ("DENY", "https://*".to_string(), "WILDCARD"), + ("DENY", "git://*".to_string(), "WILDCARD"), + ("DENY", "docker-image+blob://*".to_string(), "WILDCARD"), + ] + .into_iter() + .map(|(a, i, m)| (a.to_string(), i, m.to_string())) + .collect(); + assert_eq!(rules, expected); + // No pinned images: every image source is denied. + let empty = document([]).unwrap(); + assert!(!allowed( + &empty, + &format!("docker-image://docker.io/library/alpine@{A}") + )); + } + + #[test] + fn only_digest_pinned_docker_hub_references_are_accepted() { + for reference in [ + "alpine:3.22".to_string(), + "alpine".to_string(), + format!("ghcr.io/example/app@{A}"), + A.to_string(), + "not a reference".to_string(), + ] { + let error = document([reference.as_str()]).unwrap_err(); + assert_eq!(error.code, "native_build_base_image", "{reference}"); + assert!(!error.message.contains(&reference), "{reference}"); + } + } +} diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs new file mode 100644 index 000000000..2f9a08323 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -0,0 +1,1323 @@ +//! Pure Dockerfile image-reference scanner for native image builds. +//! +//! A native build pins every image it may pull by digest before BuildKit runs, so it +//! must know those images first. This module reads a Dockerfile, never executes it, +//! and reports the images a build of the selected target can pull: `FROM`, +//! `COPY --from=`, `RUN --mount=...,from=`, and the same forms inside +//! `ONBUILD` triggers of the Dockerfile's own stages. It follows the Dockerfile +//! frontend built into the pinned engine (BuildKit 0.30): +//! +//! - physical lines, comments, parser directives (`escape`), line continuations and +//! heredoc bodies of `RUN`, `COPY` and `ADD` are read as the frontend reads them; +//! - `FROM` names and `--platform` values expand `$NAME`/`${NAME}` from the global +//! `ARG`s declared before the first `FROM`, overridden by the build's literal +//! arguments, plus the frontend's platform arguments for a `linux/arm64` build; +//! - `COPY --from` and `RUN --mount` `from=` values are literal (the frontend refuses +//! expansion there); a value naming a stage, or a `COPY --from` stage index, is a +//! stage, `scratch` is empty, and anything else is an image; +//! - only stages reachable from the target contribute images. +//! +//! Whatever the scanner cannot resolve exactly refuses instead of guessing: a frontend +//! selected by `# syntax=` (the frontend itself would be pulled), `${NAME:-...}` and +//! other modifiers, quotes or escapes in references, unknown or uncertain variables, +//! a platform other than `linux/arm64`, an image whose name equals a stage name (a +//! pinned context of that name would replace the stage), duplicate stage names, and +//! unknown instructions or flags. Every image must be a public Docker Hub reference. +//! +//! Messages carry line numbers only; they never echo Dockerfile text or argument +//! values. `ADD` sources the engine would fetch remotely (URLs, git refs, variables +//! that could become either) refuse. Not covered here: `ONBUILD` triggers stored in +//! a base image's own configuration. They are not enforced until the build step +//! applies the source policy in [`super::build_policy`], which denies network +//! sources and unpinned images at solve time. +use super::image_reference::{ImageReference, NamedReference, normalize}; +use crate::CandidateError; +use std::collections::{BTreeMap, BTreeSet}; + +/// Largest Dockerfile the scanner reads. +pub const MAX_DOCKERFILE_BYTES: usize = 1024 * 1024; +/// Most distinct images one build may pin. +pub const MAX_IMAGE_REFERENCES: usize = 32; +/// Most build stages one Dockerfile may declare. +pub const MAX_STAGES: usize = 256; +/// Largest expanded global argument value, `FROM` name or `--platform` value. +const MAX_ARGUMENT_BYTES: usize = 4096; +/// Most bytes of expanded global argument values held at once. +const MAX_GLOBAL_ARGUMENT_BYTES: usize = 256 * 1024; + +/// Platform arguments whose values the scanner knows for a `linux/arm64` build. +const KNOWN_PLATFORM_ARGS: [(&str, &str); 4] = [ + ("BUILDOS", "linux"), + ("BUILDARCH", "arm64"), + ("TARGETOS", "linux"), + ("TARGETARCH", "arm64"), +]; +/// Frontend-defined arguments whose exact text the scanner does not assume. +const UNCERTAIN_FRONTEND_ARGS: [&str; 8] = [ + "BUILDPLATFORM", + "BUILDOSVERSION", + "BUILDVARIANT", + "TARGETPLATFORM", + "TARGETOSVERSION", + "TARGETVARIANT", + "TARGETSTAGE", + "SOURCE_DATE_EPOCH", +]; +/// `--platform` spellings that name a frontend platform argument, with its name. +const PLATFORM_VARIABLES: [(&str, &str); 4] = [ + ("$BUILDPLATFORM", "BUILDPLATFORM"), + ("${BUILDPLATFORM}", "BUILDPLATFORM"), + ("$TARGETPLATFORM", "TARGETPLATFORM"), + ("${TARGETPLATFORM}", "TARGETPLATFORM"), +]; +const KNOWN_INSTRUCTIONS: [&str; 18] = [ + "add", + "arg", + "cmd", + "copy", + "entrypoint", + "env", + "expose", + "from", + "healthcheck", + "label", + "maintainer", + "onbuild", + "run", + "shell", + "stopsignal", + "user", + "volume", + "workdir", +]; + +/// One scan: the Dockerfile bytes, the build target and the build's literal arguments. +pub struct ScanRequest<'a> { + pub dockerfile: &'a [u8], + /// Stage name to build; `None` builds the last stage. + pub target: Option<&'a str>, + /// Literal build arguments. Values are used for substitution only and never reported. + pub args: &'a BTreeMap, +} + +/// A base image the build may pull. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BaseImage { + /// The familiar name the frontend matches named build contexts against, for + /// example `node:24` or `library/alpine@sha256:...` as `alpine@sha256:...`. + pub context_name: String, + /// The normalized public Docker Hub reference. + pub reference: NamedReference, +} + +impl BaseImage { + /// The Docker Hub `namespace/repository` path. + pub fn repository(&self) -> &str { + &self.reference.path + } +} + +/// One declared build stage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Stage { + pub name: Option, + /// Whether building the target needs this stage. + pub reachable: bool, +} + +/// The scan result: stages, the selected target and the distinct images in first-use order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DockerfileScan { + pub stages: Vec, + pub target: usize, + pub images: Vec, +} + +fn refusal(line: Option, message: &str) -> CandidateError { + let message = match line { + Some(line) => format!("Dockerfile line {line}: {message}"), + None => format!("Dockerfile: {message}"), + }; + CandidateError::new("native_build_dockerfile", message) +} + +/// Scan a Dockerfile for the images a native build of `request.target` can pull. +pub fn scan(request: &ScanRequest<'_>) -> Result { + validate_arguments(request.args)?; + if request.dockerfile.len() > MAX_DOCKERFILE_BYTES { + return Err(refusal(None, "exceeds the 1 MiB scan limit.")); + } + let text = + std::str::from_utf8(request.dockerfile).map_err(|_| refusal(None, "is not UTF-8 text."))?; + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + refuse_frontend_selection(text)?; + let parsed = parse(text)?; + resolve(&parsed, request) +} + +fn validate_arguments(args: &BTreeMap) -> Result<(), CandidateError> { + for name in args.keys() { + let reserved = name.starts_with("BUILDKIT_") + || KNOWN_PLATFORM_ARGS.iter().any(|(known, _)| known == name) + || UNCERTAIN_FRONTEND_ARGS.contains(&name.as_str()); + if reserved { + return Err(CandidateError::new( + "native_build_argument", + "Build arguments may not set BuildKit frontend or platform arguments.", + )); + } + } + Ok(()) +} + +/// Refuse anything the frontend would treat as a request for another frontend: +/// `# syntax=` or `// syntax=` in the leading comment block (after an optional +/// shebang) and a JSON document. Broader than the frontend on purpose. +fn refuse_frontend_selection(text: &str) -> Result<(), CandidateError> { + if text.trim_start().starts_with('{') { + return Err(refusal(None, "JSON-form Dockerfiles are not supported.")); + } + let mut lines = text.split('\n').peekable(); + if lines.peek().is_some_and(|line| line.starts_with("#!")) { + lines.next(); + } + for line in lines { + let line = line.trim_start(); + let Some(rest) = line.strip_prefix('#').or_else(|| line.strip_prefix("//")) else { + break; + }; + let rest = rest.trim_start(); + let key_end = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[..key_end].eq_ignore_ascii_case("syntax") + && rest[key_end..].trim_start().starts_with('=') + { + return Err(refusal( + None, + "a `# syntax=` frontend directive is not supported for native builds.", + )); + } + } + Ok(()) +} + +/// A use of `COPY --from` or `RUN --mount=...,from=` with its literal value. +#[derive(Debug)] +struct Use { + line: usize, + value: String, + /// `COPY --from` accepts a stage index; mount `from=` does not. + index_allowed: bool, +} + +#[derive(Debug)] +struct RawStage { + line: usize, + base: String, + platform: Option, + name: Option, + uses: Vec, + onbuild: Vec, +} + +#[derive(Debug, Default)] +struct Parsed { + /// Global `ARG` declarations before the first `FROM`: name and raw default. + meta_args: Vec<(String, Option)>, + stages: Vec, +} + +/// The `escape` directive from the leading directive block, as the frontend reads it. +fn escape_token(lines: &[&str]) -> Result { + let mut seen = BTreeSet::new(); + let mut escape = '\\'; + for (index, raw) in lines.iter().enumerate() { + let line = trim_newline(raw).trim_start(); + let Some(rest) = line.strip_prefix('#') else { + break; + }; + let Some((key, value)) = directive(rest.trim_start()) else { + break; + }; + let key = key.to_ascii_lowercase(); + if !matches!(key.as_str(), "syntax" | "escape" | "check") { + break; + } + if !seen.insert(key.clone()) { + return Err(refusal(Some(index + 1), "a parser directive is repeated.")); + } + if key == "escape" { + escape = match value { + "\\" => '\\', + "`" => '`', + _ => return Err(refusal(Some(index + 1), "invalid escape directive.")), + }; + } + } + Ok(escape) +} + +/// `^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$` +fn directive(text: &str) -> Option<(&str, &str)> { + let first = text.chars().next()?; + if !first.is_ascii_alphabetic() { + return None; + } + let key_end = text + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(text.len()); + let rest = text[key_end..].trim_start().strip_prefix('=')?; + let value = rest.trim(); + (!value.is_empty()).then_some((&text[..key_end], value)) +} + +fn trim_newline(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +/// The frontend's continuation rule: a trailing escape token (optionally followed by +/// spaces or tabs) continues the line unless the token itself is escaped. +fn trim_continuation(line: &str, escape: char) -> (&str, bool) { + let trimmed = line.trim_end_matches([' ', '\t']); + if let Some(before) = trimmed.strip_suffix(escape) { + if before.is_empty() { + return ("", false); + } + if !before.ends_with(escape) { + return (before, false); + } + } + (line, true) +} + +fn is_frontend_whitespace(c: char) -> bool { + matches!(c, '\t' | '\u{b}' | '\u{c}' | '\r' | ' ') +} + +struct Instruction { + command: String, + flags: Vec, + args: String, +} + +/// Split `COMMAND [--flags] args` the way the frontend's `splitCommand` does. +fn split_instruction(line: &str, escape: char) -> Instruction { + let line = line.trim(); + let (command, rest) = match line.find(is_frontend_whitespace) { + Some(at) => ( + &line[..at], + line[at..].trim_start_matches(is_frontend_whitespace), + ), + None => (line, ""), + }; + let (args, flags) = if rest.is_empty() { + (String::new(), Vec::new()) + } else { + extract_flags(rest, escape) + }; + Instruction { + command: command.to_ascii_lowercase(), + flags, + args: args.trim().to_string(), + } +} + +/// Port of the frontend's `extractBuilderFlags`: leading `--name[=value]` words with +/// quotes removed and escapes applied; returns the remaining text and the flag words. +fn extract_flags(line: &str, escape: char) -> (String, Vec) { + #[derive(PartialEq)] + enum Phase { + Spaces, + Word, + Quote, + } + let chars: Vec<(usize, char)> = line.char_indices().collect(); + let mut words = Vec::new(); + let mut phase = Phase::Spaces; + let mut word = String::new(); + let mut quote = '\0'; + let mut blank_ok = false; + let mut pos = 0; + loop { + let at_end = pos == chars.len(); + if phase == Phase::Spaces { + if at_end { + break; + } + let ch = chars[pos].1; + if ch.is_whitespace() { + pos += 1; + continue; + } + if ch != '-' || pos + 1 == chars.len() || chars[pos + 1].1 != '-' { + return (line[chars[pos].0..].to_string(), words); + } + phase = Phase::Word; + } + if at_end { + if word != "--" && (blank_ok || !word.is_empty()) { + words.push(word); + } + break; + } + let mut ch = chars[pos].1; + if phase == Phase::Word { + if ch.is_whitespace() { + phase = Phase::Spaces; + if word == "--" { + return (line[chars[pos].0..].to_string(), words); + } + if blank_ok || !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + word.clear(); + blank_ok = false; + pos += 1; + continue; + } + if ch == '\'' || ch == '"' { + quote = ch; + blank_ok = true; + phase = Phase::Quote; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + continue; + } + // Phase::Quote + if ch == quote { + phase = Phase::Word; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + phase = Phase::Word; + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + } + (String::new(), words) +} + +#[derive(Clone, Copy, PartialEq)] +enum FlagKind { + Value, + Bool, + Repeated, +} + +/// Parse flag words against an instruction's flag set; returns values by flag name. +/// Unknown, duplicated or malformed flags refuse, as the frontend would fail on them. +fn parse_flags( + words: &[String], + allowed: &[(&str, FlagKind)], + line: usize, +) -> Result>, CandidateError> { + let mut values: BTreeMap> = BTreeMap::new(); + for word in words { + let Some(body) = word.strip_prefix("--") else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let (name, value) = match body.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (body, None), + }; + let Some((_, kind)) = allowed.iter().find(|(known, _)| *known == name) else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let entry = values.entry(name.to_string()).or_default(); + let malformed = match kind { + FlagKind::Value => value.is_none() || !entry.is_empty(), + FlagKind::Bool => { + !entry.is_empty() + || value.is_some_and(|v| !matches!(v, "true" | "false" | "1" | "0")) + } + FlagKind::Repeated => value.is_none(), + }; + if malformed { + return Err(refusal( + Some(line), + "malformed or repeated instruction flag.", + )); + } + entry.push(value.unwrap_or("").to_string()); + } + Ok(values) +} + +const FROM_FLAGS: [(&str, FlagKind); 1] = [("platform", FlagKind::Value)]; +const COPY_FLAGS: [(&str, FlagKind); 6] = [ + ("chown", FlagKind::Value), + ("from", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("exclude", FlagKind::Repeated), + ("parents", FlagKind::Bool), +]; +const ADD_FLAGS: [(&str, FlagKind); 7] = [ + ("chown", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("keep-git-dir", FlagKind::Bool), + ("checksum", FlagKind::Value), + ("unpack", FlagKind::Bool), + ("exclude", FlagKind::Repeated), +]; +const RUN_FLAGS: [(&str, FlagKind); 4] = [ + ("mount", FlagKind::Repeated), + ("network", FlagKind::Value), + ("security", FlagKind::Value), + ("device", FlagKind::Repeated), +]; + +/// A literal `--from`/`from=` value: anything the frontend would expand or unquote refuses. +fn literal_source(value: &str, escape: char, line: usize) -> Result { + if value.is_empty() + || value + .chars() + .any(|c| matches!(c, '$' | '\'' | '"' | '\\' | '`') || c == escape || c.is_whitespace()) + { + return Err(refusal( + Some(line), + "a build source must be a literal stage or image name without variables or quotes.", + )); + } + Ok(value.to_string()) +} + +/// Image and stage uses of one `COPY`/`ADD`/`RUN` instruction. +fn instruction_uses( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result, CandidateError> { + let mut uses = Vec::new(); + match instruction.command.as_str() { + "copy" => { + let flags = parse_flags(&instruction.flags, ©_FLAGS, line)?; + if let Some(value) = flags.get("from").and_then(|v| v.first()) { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: true, + }); + } + } + "add" => { + let flags = parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + if flags.contains_key("checksum") || flags.contains_key("keep-git-dir") { + return Err(refusal( + Some(line), + "ADD --checksum and --keep-git-dir apply only to remote sources, which native builds do not fetch.", + )); + } + refuse_remote_add_sources(&instruction.args, escape, line)?; + } + "run" => { + let flags = parse_flags(&instruction.flags, &RUN_FLAGS, line)?; + for mount in flags.get("mount").into_iter().flatten() { + if mount.contains('"') { + return Err(refusal( + Some(line), + "quoted mount options are not supported.", + )); + } + for field in mount.split(',') { + if let Some((key, value)) = field.split_once('=') { + if key.eq_ignore_ascii_case("from") { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: false, + }); + } + } + } + } + } + _ => {} + } + Ok(uses) +} + +/// Refuse an `ADD` that could fetch a remote source. +/// +/// The engine fetches `ADD` URLs and git refs from the guest's own network +/// namespace, outside the build network's isolation, so a native build may add +/// only local build-context files. The frontend splits a shell-form `ADD` on +/// whitespace (or reads a JSON string array), takes every word but the last as a +/// source, and expands variables in each. A source is remote when it starts with +/// `scheme://` (http, https, git, ssh) or is an scp-style `user@host:path` git ref. +/// This refuses a superset: after dropping quotes and escapes, any source with +/// `://`, with `@` followed by `:`, or with a `$` that could expand to either. +fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<(), CandidateError> { + let words: Vec = if let Some(words) = json_words(args, line)? { + words + } else { + args.split(is_frontend_whitespace) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect() + }; + // Every word but the destination; a lone word is checked as well. + let sources = match words.len() { + 0 | 1 => &words[..], + count => &words[..count - 1], + }; + for source in sources { + let bare: String = source + .chars() + .filter(|&c| !matches!(c, '\'' | '"' | '\\') && c != escape) + .collect(); + let scp_like = bare.find('@').is_some_and(|at| bare[at..].contains(':')); + if bare.contains("://") || scp_like || bare.contains('$') { + return Err(refusal( + Some(line), + "ADD may use only local build context sources, without URLs, git refs or variables.", + )); + } + } + Ok(()) +} + +/// The words of a JSON-form (exec-form) argument list, or `None` for shell form. +/// +/// The frontend's `parseJSON` trims leading whitespace, requires `[`, and decodes +/// with Go's `json.Decoder`: it reads only the first value and ignores what +/// follows, and it decodes lone UTF-16 surrogate escapes leniently to U+FFFD. Its +/// result decides whether heredocs apply and which words are `ADD` sources, so a +/// disagreement could hide instructions or sources. The first value is decoded the +/// same way, trailing text included. Where the strict decoder fails on text that +/// has a `\u` escape, the frontend may still accept it, so that refuses. +fn json_words(args: &str, line: usize) -> Result>, CandidateError> { + let args = args.trim_start(); + if !args.starts_with('[') { + return Ok(None); + } + let first = serde_json::Deserializer::from_str(args) + .into_iter::() + .next(); + match first { + Some(Ok(serde_json::Value::Array(values))) => values + .into_iter() + .map(|value| match value { + serde_json::Value::String(word) => Ok(word), + _ => Err(refusal( + Some(line), + "a JSON-form argument list must contain strings.", + )), + }) + .collect::, _>>() + .map(Some), + _ if args.contains("\\u") => Err(refusal( + Some(line), + "a JSON-form argument list must be strict JSON without lone surrogate escapes.", + )), + _ => Ok(None), + } +} + +/// Shell words with quotes and escapes kept raw, as the heredoc detector lexes a line. +/// +/// Like the frontend's lexer, an unquoted `<<` keeps the tabs, carriage returns and +/// spaces after it in the same word, so `<< EOF` is one heredoc word. A `-` stops +/// that, so `<<- EOF` is two words and not a heredoc. +fn raw_words(line: &str) -> Option> { + let mut words = Vec::new(); + let mut word = String::new(); + let mut in_word = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '<' if chars.peek() == Some(&'<') => { + chars.next(); + word.push_str("<<"); + while let Some(&space) = chars.peek() { + if !matches!(space, '\t' | '\r' | ' ') { + break; + } + word.push(space); + chars.next(); + } + in_word = true; + } + c if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut word)); + in_word = false; + } + } + '\\' => { + word.push(c); + word.push(chars.next()?); + in_word = true; + } + '\'' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\'' { + break; + } + } + in_word = true; + } + '"' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\\' { + word.push(chars.next()?); + } else if next == '"' { + break; + } + } + in_word = true; + } + _ => { + word.push(c); + in_word = true; + } + } + } + if in_word { + words.push(word); + } + Some(words) +} + +/// A heredoc word, `^(\d*)<<(-?)\s*([^<]*)$`: its terminator and whether tabs are chomped. +fn heredoc(word: &str, line: usize) -> Result, CandidateError> { + let rest = word.trim_start_matches(|c: char| c.is_ascii_digit()); + let Some(rest) = rest.strip_prefix("<<") else { + return Ok(None); + }; + let (rest, chomp) = match rest.strip_prefix('-') { + Some(rest) => (rest, true), + None => (rest, false), + }; + let rest = rest.trim_start_matches(['\t', '\n', '\u{c}', '\r', ' ']); + if rest.is_empty() || rest.contains('<') { + return Ok(None); + } + if rest.contains('\\') { + return Err(refusal( + Some(line), + "escaped heredoc delimiters are not supported.", + )); + } + let mut name = String::new(); + let mut quote = None; + for c in rest.chars() { + match (quote, c) { + (None, '\'' | '"') => quote = Some(c), + (Some(open), c) if c == open => quote = None, + (None, c) if c.is_whitespace() => return Ok(None), + _ => name.push(c), + } + } + if quote.is_some() || name.is_empty() { + return Err(refusal(Some(line), "unrecognized heredoc delimiter.")); + } + Ok(Some((name, chomp))) +} + +fn heredoc_capable( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result { + let inner; + let instruction = if instruction.command == "onbuild" { + inner = split_instruction(&instruction.args, escape); + &inner + } else { + instruction + }; + Ok( + matches!(instruction.command.as_str(), "run" | "copy" | "add") + && json_words(&instruction.args, line)?.is_none(), + ) +} + +fn parse(text: &str) -> Result { + let lines: Vec<&str> = text.split('\n').collect(); + let escape = escape_token(&lines)?; + let mut parsed = Parsed::default(); + let mut index = 0; + while index < lines.len() { + let start = index + 1; + let first = trim_newline(lines[index]).trim_start(); + index += 1; + if first.starts_with('#') { + continue; + } + let (first, mut end) = trim_continuation(first, escape); + if end && first.is_empty() { + continue; + } + let mut logical = first.to_string(); + while !end && index < lines.len() { + let raw = trim_newline(lines[index]); + index += 1; + let trimmed = raw.trim_start(); + if trimmed.starts_with('#') || trimmed.is_empty() { + continue; + } + let (part, done) = trim_continuation(raw, escape); + logical.push_str(part); + end = done; + } + let instruction = split_instruction(&logical, escape); + if heredoc_capable(&instruction, escape, start)? && logical.contains("<<") { + let words = + raw_words(&logical).ok_or_else(|| refusal(Some(start), "unterminated quote."))?; + for word in words { + let Some((terminator, chomp)) = heredoc(&word, start)? else { + continue; + }; + let mut terminated = false; + while index < lines.len() { + let body = trim_newline(lines[index]); + index += 1; + let body = if chomp { + body.trim_start_matches('\t') + } else { + body + }; + if body == terminator { + terminated = true; + break; + } + } + if !terminated { + return Err(refusal(Some(start), "unterminated heredoc.")); + } + } + } + dispatch(&mut parsed, instruction, escape, start)?; + } + if parsed.stages.is_empty() { + return Err(refusal(None, "declares no build stage.")); + } + Ok(parsed) +} + +fn dispatch( + parsed: &mut Parsed, + instruction: Instruction, + escape: char, + line: usize, +) -> Result<(), CandidateError> { + let command = instruction.command.as_str(); + if !KNOWN_INSTRUCTIONS.contains(&command) { + return Err(refusal(Some(line), "unknown instruction.")); + } + if parsed.stages.is_empty() && command != "arg" && command != "from" { + return Err(refusal(Some(line), "only ARG may precede the first FROM.")); + } + match command { + "from" => { + let flags = parse_flags(&instruction.flags, &FROM_FLAGS, line)?; + let words: Vec<&str> = instruction + .args + .split(is_frontend_whitespace) + .filter(|w| !w.is_empty()) + .collect(); + let name = match words.as_slice() { + [_] => None, + [_, keyword, name] if keyword.eq_ignore_ascii_case("as") => { + let name = name.to_ascii_lowercase(); + let valid = name.starts_with(|c: char| c.is_ascii_lowercase()) + && name.chars().all(|c| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || matches!(c, '-' | '_' | '.') + }); + if !valid { + return Err(refusal(Some(line), "invalid build stage name.")); + } + // The frontend links a later `FROM scratch` (or `--from=scratch`) to + // a stage of that name instead of the empty image. + if name == "scratch" { + return Err(refusal( + Some(line), + "a build stage may not be named scratch.", + )); + } + Some(name) + } + _ => return Err(refusal(Some(line), "FROM must name one image or stage.")), + }; + if name + .as_ref() + .is_some_and(|name| parsed.stages.iter().any(|s| s.name.as_ref() == Some(name))) + { + return Err(refusal(Some(line), "duplicate build stage name.")); + } + if parsed.stages.len() == MAX_STAGES { + return Err(refusal(Some(line), "declares more than 256 build stages.")); + } + parsed.stages.push(RawStage { + line, + base: words[0].to_string(), + platform: flags.get("platform").and_then(|v| v.first()).cloned(), + name, + uses: Vec::new(), + onbuild: Vec::new(), + }); + } + "arg" => { + let words = argument_words(&instruction.args, escape); + if words.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + for word in words { + let (name, value) = match word.split_once('=') { + Some((name, value)) => (name.to_string(), Some(value.to_string())), + None => (word, None), + }; + if name.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + // The frontend would unquote, unescape or expand any other name, so a + // literal compare could miss an override of a platform argument. + if !is_identifier(&name) { + return Err(refusal( + Some(line), + "ARG names must be plain [A-Za-z_][A-Za-z0-9_]* identifiers.", + )); + } + if parsed.stages.is_empty() { + parsed.meta_args.push((name, value)); + } + } + } + "onbuild" => { + if !instruction.flags.is_empty() { + return Err(refusal(Some(line), "unsupported instruction flag.")); + } + let inner = split_instruction(&instruction.args, escape); + if !KNOWN_INSTRUCTIONS.contains(&inner.command.as_str()) + || matches!(inner.command.as_str(), "onbuild" | "from" | "maintainer") + { + return Err(refusal(Some(line), "unsupported ONBUILD trigger.")); + } + let uses = instruction_uses(&inner, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.onbuild.extend(uses); + } + } + _ => { + let uses = instruction_uses(&instruction, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.uses.extend(uses); + } + } + } + Ok(()) +} + +/// Port of the frontend's `parseWords`: whitespace-separated words, quotes kept, +/// the escape token keeping the character it escapes. +fn argument_words(text: &str, escape: char) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut started = false; + let mut quote: Option = None; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + None if c.is_whitespace() => { + if started { + words.push(std::mem::take(&mut word)); + started = false; + } + } + None => { + started = true; + if c == '\'' || c == '"' { + quote = Some(c); + word.push(c); + } else if c == escape { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + Some(open) => { + if c == open { + quote = None; + word.push(c); + } else if c == escape && open != '\'' { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + } + } + if started { + words.push(word); + } + words +} + +fn is_identifier(name: &str) -> bool { + name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// A global argument value: known text, or present with text the scanner cannot know. +type Env = BTreeMap>; + +/// Append `piece` to `out` unless that would pass `cap` bytes. Checked before the +/// append, so an oversized value never allocates past the cap. +fn push_bounded(out: &mut String, piece: &str, cap: usize) -> Option<()> { + if out.len().checked_add(piece.len())? > cap { + return None; + } + out.push_str(piece); + Some(()) +} + +/// Expand `$NAME` and `${NAME}` into at most `cap` bytes. Any other `$` form, quote, +/// escape, unknown or uncertain variable, or a result over `cap` yields `None`. +fn expand(word: &str, env: &Env, cap: usize) -> Option { + let mut out = String::new(); + let mut chars = word.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '$' => { + let braced = chars.peek() == Some(&'{'); + if braced { + chars.next(); + } + let mut name = String::new(); + while let Some(&next) = chars.peek() { + if next.is_ascii_alphanumeric() || next == '_' { + name.push(next); + chars.next(); + } else { + break; + } + } + if braced && chars.next() != Some('}') { + return None; + } + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + push_bounded(&mut out, env.get(&name)?.as_deref()?, cap)?; + } + '\'' | '"' | '\\' | '`' => return None, + _ => push_bounded(&mut out, c.encode_utf8(&mut [0; 4]), cap)?, + } + } + Some(out) +} + +/// The value of a global `ARG` default, in at most `cap` bytes: quotes removed, +/// `$NAME`/`${NAME}` expanded. Anything else, including a value over `cap`, is +/// recorded as uncertain and refuses only when a reference uses it. +fn argument_value(raw: &str, env: &Env, cap: usize) -> Option { + let mut out = String::new(); + let mut rest = raw; + while let Some(c) = rest.chars().next() { + let remaining = cap - out.len(); + match c { + '\'' => { + let body = &rest[1..]; + let end = body.find('\'')?; + push_bounded(&mut out, &body[..end], cap)?; + rest = &body[end + 1..]; + } + '"' => { + let body = &rest[1..]; + let end = body.find('"')?; + out.push_str(&expand(&body[..end], env, remaining)?); + rest = &body[end + 1..]; + } + _ => { + let end = rest.find(['\'', '"']).unwrap_or(rest.len()); + out.push_str(&expand(&rest[..end], env, remaining)?); + rest = &rest[end..]; + } + } + } + Some(out) +} + +/// The global arguments `FROM` names and `--platform` values expand against. +/// +/// Defaults expand eagerly, so `ARG A=$A$A` repeated would double without bound. +/// Each expanded default is capped at [`MAX_ARGUMENT_BYTES`] and all held values at +/// [`MAX_GLOBAL_ARGUMENT_BYTES`]; a default past either cap is uncertain. +fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { + let mut env: Env = KNOWN_PLATFORM_ARGS + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(); + for name in UNCERTAIN_FRONTEND_ARGS { + env.insert(name.to_string(), None); + } + let mut held: usize = KNOWN_PLATFORM_ARGS.iter().map(|(_, v)| v.len()).sum(); + for (name, default) in &parsed.meta_args { + let value = if let Some(value) = args.get(name) { + Some(value.clone()) + } else if let Some(default) = default { + let cap = MAX_ARGUMENT_BYTES.min(MAX_GLOBAL_ARGUMENT_BYTES.saturating_sub(held)); + argument_value(default, &env, cap) + } else { + continue; + }; + held += value.as_ref().map_or(0, String::len); + if let Some(Some(old)) = env.insert(name.clone(), value) { + held -= old.len(); + } + } + // The frontend rewrites this one from the resolved build epoch. + env.insert("SOURCE_DATE_EPOCH".into(), None); + env +} + +#[derive(Clone, Debug)] +enum Source { + Scratch, + Stage(usize), + Image(String), +} + +/// Whether a reachable stage's `--platform` selects `linux/arm64`. +/// +/// `$BUILDPLATFORM` and `$TARGETPLATFORM` select it only while they keep the +/// frontend's value. A global `ARG` default replaces that value (the frontend +/// expands `--platform` against the global arguments), so an overridden one must +/// itself expand to `linux/arm64`. A bare global `ARG` keeps the frontend's value. +fn platform_supported(raw: &str, env: &Env, parsed: &Parsed) -> bool { + let frontend_value = PLATFORM_VARIABLES + .iter() + .find(|(spelling, _)| *spelling == raw) + .is_some_and(|(_, name)| { + !parsed + .meta_args + .iter() + .any(|(declared, default)| declared == name && default.is_some()) + }); + frontend_value + || expand(raw, env, MAX_ARGUMENT_BYTES) + .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) +} + +fn stage_by_name(parsed: &Parsed, name: &str) -> Option { + let name = name.to_ascii_lowercase(); + parsed + .stages + .iter() + .position(|stage| stage.name.as_deref() == Some(name.as_str())) +} + +fn use_source(parsed: &Parsed, source: &Use) -> Result { + let value = source.value.as_str(); + if source.index_allowed && value.bytes().all(|b| b.is_ascii_digit()) { + return value + .parse::() + .ok() + .filter(|index| *index < parsed.stages.len()) + .map(Source::Stage) + .ok_or_else(|| refusal(Some(source.line), "invalid build stage index.")); + } + if source.index_allowed && value.starts_with(['+', '-']) { + return Err(refusal(Some(source.line), "invalid build stage index.")); + } + if let Some(index) = stage_by_name(parsed, value) { + return Ok(Source::Stage(index)); + } + if value == "scratch" { + return Ok(Source::Scratch); + } + Ok(Source::Image(value.to_string())) +} + +fn base_image(name: &str, line: usize) -> Result { + match normalize(name) { + Ok(ImageReference::Named(reference)) if reference.docker_hub_repository().is_some() => { + let path = reference + .path + .strip_prefix("library/") + .unwrap_or(&reference.path); + let mut familiar = path.to_string(); + if let Some(tag) = &reference.tag { + familiar.push(':'); + familiar.push_str(tag); + } + if let Some(digest) = &reference.digest { + familiar.push('@'); + familiar.push_str(digest); + } + let context_name = familiar + .strip_suffix(":latest") + .map_or(familiar.clone(), str::to_string); + Ok(BaseImage { + context_name, + reference, + }) + } + _ => Err(CandidateError::new( + "native_build_base_image", + format!( + "Dockerfile line {line}: build images must be public Docker Hub [namespace/]repository[:tag][@sha256] references." + ), + )), + } +} + +fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result { + let env = global_env(parsed, request.args); + // Each stage's base as the frontend registers it: an earlier stage of that name, + // `scratch`, or an image. `None` marks a name the scanner cannot expand. Stage + // names are stored lowercase and the frontend lowercases a base name before its + // stage lookup (as `stage_by_name` does for `--from` and the target), so `FROM Base` + // names a stage declared `AS base`. A name that matches no stage stays an image + // reference, where uppercase is invalid. + let mut bases: Vec> = Vec::with_capacity(parsed.stages.len()); + for (index, stage) in parsed.stages.iter().enumerate() { + let base = expand(&stage.base, &env, MAX_ARGUMENT_BYTES) + .filter(|name| !name.is_empty()) + .map(|name| { + let lowered = name.to_ascii_lowercase(); + if name == "scratch" { + Source::Scratch + } else if let Some(earlier) = parsed.stages[..index] + .iter() + .position(|s| s.name.as_deref() == Some(lowered.as_str())) + { + Source::Stage(earlier) + } else { + Source::Image(name) + } + }); + bases.push(base); + } + let target = match request.target { + Some(target) => stage_by_name(parsed, target) + .ok_or_else(|| refusal(None, "the build target stage is not declared."))?, + None => parsed.stages.len() - 1, + }; + + let mut reachable = vec![false; parsed.stages.len()]; + let mut pending = vec![target]; + let mut images: Vec<(String, usize)> = Vec::new(); + while let Some(index) = pending.pop() { + if std::mem::replace(&mut reachable[index], true) { + continue; + } + let stage = &parsed.stages[index]; + if let Some(platform) = &stage.platform { + if !platform_supported(platform, &env, parsed) { + return Err(refusal( + Some(stage.line), + "native builds support only the linux/arm64 platform.", + )); + } + } + let mut sources = Vec::new(); + match &bases[index] { + Some(base) => sources.push((base.clone(), stage.line)), + None => { + return Err(refusal( + Some(stage.line), + "the FROM name uses a variable the scanner cannot resolve.", + )); + } + } + for used in &stage.uses { + sources.push((use_source(parsed, used)?, used.line)); + } + // ONBUILD triggers of every ancestor stage run in this stage (a superset of the + // frontend's inheritance, so no trigger image is missed). + let mut ancestor = &bases[index]; + while let Some(Source::Stage(parent)) = ancestor { + for used in &parsed.stages[*parent].onbuild { + sources.push((use_source(parsed, used)?, used.line)); + } + ancestor = &bases[*parent]; + } + for (source, line) in sources { + match source { + Source::Scratch => {} + Source::Stage(next) => pending.push(next), + Source::Image(name) => images.push((name, line)), + } + } + } + + let mut admitted: Vec = Vec::new(); + let mut lines: Vec<(usize, BaseImage)> = Vec::new(); + for (name, line) in images { + lines.push((line, base_image(&name, line)?)); + } + lines.sort_by_key(|(line, _)| *line); + for (line, image) in lines { + if parsed + .stages + .iter() + .any(|stage| stage.name.as_deref() == Some(image.context_name.as_str())) + { + return Err(refusal( + Some(line), + "an image name equals a build stage name; rename the stage.", + )); + } + if !admitted + .iter() + .any(|known| known.reference == image.reference) + { + if admitted.len() == MAX_IMAGE_REFERENCES { + return Err(refusal(None, "uses more than 32 distinct images.")); + } + admitted.push(image); + } + } + Ok(DockerfileScan { + stages: parsed + .stages + .iter() + .zip(reachable) + .map(|(stage, reachable)| Stage { + name: stage.name.clone(), + reachable, + }) + .collect(), + target, + images: admitted, + }) +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs new file mode 100644 index 000000000..b4922e6c5 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -0,0 +1,809 @@ +use super::*; + +fn scan_with( + dockerfile: &str, + target: Option<&str>, + args: &[(&str, &str)], +) -> Result { + let args: BTreeMap = args + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + scan(&ScanRequest { + dockerfile: dockerfile.as_bytes(), + target, + args: &args, + }) +} + +fn images(dockerfile: &str) -> Vec { + names(&scan_with(dockerfile, None, &[]).unwrap()) +} + +fn names(scan: &DockerfileScan) -> Vec { + scan.images.iter().map(|i| i.context_name.clone()).collect() +} + +fn code(dockerfile: &str) -> &'static str { + scan_with(dockerfile, None, &[]).unwrap_err().code +} + +#[test] +fn a_multi_stage_build_reports_base_images_and_not_stages() { + let dockerfile = "\ +ARG RUNTIME_VERSION=24 +FROM example/toolchain:1.2 AS build +WORKDIR /src +COPY . . +RUN make + +FROM node:${RUNTIME_VERSION}-slim AS runtime +COPY --from=build /src/out /app +COPY --from=busybox:1.37 /bin/busybox /bin/busybox +CMD [\"node\", \"/app/index.js\"] +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["example/toolchain:1.2", "node:24-slim", "busybox:1.37"] + ); + assert_eq!(scan.target, 1); + assert!(scan.stages.iter().all(|stage| stage.reachable)); + assert_eq!(scan.images[1].repository(), "library/node"); + assert_eq!(scan.images[1].reference.tag.as_deref(), Some("24-slim")); +} + +#[test] +fn literal_build_arguments_override_global_defaults_and_chain() { + let dockerfile = "\ +ARG DISTRO=bookworm +ARG BASE=debian:${DISTRO} +FROM $BASE +"; + assert_eq!(images(dockerfile), ["debian:bookworm"]); + let scan = scan_with(dockerfile, None, &[("DISTRO", "trixie")]).unwrap(); + assert_eq!(names(&scan), ["debian:trixie"]); + // An argument overrides the whole default, verbatim. + let scan = scan_with(dockerfile, None, &[("BASE", "alpine:3.22")]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + // Quoted defaults lose their quotes as the frontend does. + assert_eq!( + images("ARG V=\"3.22\"\nARG R='alpine'\nFROM ${R}:$V\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn stage_args_do_not_reach_from_lines() { + // A stage ARG is not a global ARG, so the second FROM cannot resolve it. + let dockerfile = "FROM alpine:3.22 AS one\nARG TAG=1\nFROM busybox:$TAG\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + // An undeclared build argument does not become a global ARG. + assert!(scan_with("FROM busybox:$TAG\n", None, &[("TAG", "1")]).is_err()); +} + +#[test] +fn platform_arguments_known_for_arm64_resolve_and_uncertain_ones_refuse() { + assert_eq!( + images("FROM example/tool:${TARGETOS}-${TARGETARCH}\n"), + ["example/tool:linux-arm64"] + ); + assert_eq!( + code("FROM example/tool:${TARGETVARIANT}\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("ARG SOURCE_DATE_EPOCH=1\nFROM example/tool:$SOURCE_DATE_EPOCH\n"), + "native_build_dockerfile" + ); +} + +#[test] +fn variable_modifiers_quotes_and_unknown_variables_refuse() { + for dockerfile in [ + "ARG V=1\nFROM alpine:${V:-3}\n", + "ARG V=1\nFROM alpine:${V:+3}\n", + "FROM alpine:$UNDECLARED\n", + "ARG V\nFROM alpine:$V\n", + "FROM \"alpine\"\n", + "ARG V=1\nFROM alpine:${V\n", + "ARG V=${OTHER:-x}\nFROM alpine:$V\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An unresolvable default is harmless while nothing uses it. + assert_eq!( + images("ARG V=${OTHER:-x}\nFROM alpine:3.22\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn copy_from_resolves_stage_names_indexes_and_images() { + let dockerfile = "\ +FROM alpine:3.22 AS Base +FROM busybox:1.37 +COPY --from=BASE /a /a +COPY --from=0 /b /b +COPY --from=scratch /c /c +COPY --from=example/assets:7 /d /d +COPY --link --chmod=0644 --from=example/assets:7 /e /e +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["alpine:3.22", "busybox:1.37", "example/assets:7"] + ); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + for invalid in ["COPY --from=9 /a /a", "COPY --from=-1 /a /a"] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{invalid}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn from_values_are_literal_as_the_frontend_requires() { + for line in [ + "COPY --from=$IMAGE /a /a", + "COPY --from=${IMAGE} /a /a", + "COPY --from=\"$IMAGE\" /a /a", + "COPY --from=a\\$b /a /a", + "RUN --mount=type=bind,from=$IMAGE,target=/x true", + ] { + let dockerfile = format!("ARG IMAGE=alpine:3.22\nFROM busybox:1.37\n{line}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{line}"); + } +} + +#[test] +fn run_mounts_resolve_stage_names_and_images_but_not_indexes() { + let dockerfile = "\ +FROM golang:1.25 AS deps +FROM busybox:1.37 +RUN --mount=type=cache,target=/cache \\ + --mount=type=bind,from=deps,source=/go,target=/go \\ + --mount=type=bind,FROM=example/data:2,target=/data \\ + --mount=type=bind,from=0,target=/zero \\ + true +"; + assert_eq!( + images(dockerfile), + ["golang:1.25", "busybox:1.37", "example/data:2", "0"] + ); + // Quotes around the whole flag are removed before the mount options are read. + assert_eq!( + images("FROM busybox:1.37\nRUN --mount=\"type=bind,from=example/data:2,target=/d\" true\n"), + ["busybox:1.37", "example/data:2"] + ); +} + +#[test] +fn only_stages_reachable_from_the_target_contribute_images() { + let dockerfile = "\ +FROM example/lint:1 AS lint +FROM golang:1.25 AS build +COPY --from=example/proto:3 /p /p +FROM build AS test +RUN go test ./... +FROM gcr.io/distroless/static AS release +COPY --from=build /out /out +"; + let scan = scan_with(dockerfile, Some("TEST"), &[]).unwrap(); + assert_eq!(names(&scan), ["golang:1.25", "example/proto:3"]); + assert_eq!(scan.target, 2); + assert_eq!( + scan.stages.iter().map(|s| s.reachable).collect::>(), + [false, true, true, false] + ); + // The default target is the last stage, whose base is not Docker Hub. + assert_eq!( + scan_with(dockerfile, None, &[]).unwrap_err().code, + "native_build_base_image" + ); + assert_eq!( + scan_with(dockerfile, Some("missing"), &[]) + .unwrap_err() + .code, + "native_build_dockerfile" + ); +} + +#[test] +fn a_later_stage_name_used_by_an_earlier_from_is_an_image_and_refuses_as_ambiguous() { + // The frontend matches FROM only against earlier stages, so `FROM base` here is the + // image `library/base`; a pinned context named `base` would replace the stage. + let dockerfile = "FROM base AS first\nFROM alpine:3.22 AS base\nFROM first\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + assert_eq!(code("FROM node AS node\n"), "native_build_dockerfile"); +} + +#[test] +fn duplicate_or_invalid_stage_names_refuse() { + assert_eq!( + code("FROM alpine:3.22 AS a\nFROM busybox:1.37 AS A\n"), + "native_build_dockerfile" + ); + assert_eq!(code("FROM alpine:3.22 AS 1st\n"), "native_build_dockerfile"); + assert_eq!(code("FROM alpine:3.22 AS\n"), "native_build_dockerfile"); +} + +#[test] +fn onbuild_triggers_of_internal_base_stages_are_scanned() { + let dockerfile = "\ +FROM alpine:3.22 AS template +ONBUILD COPY --from=example/plugin:5 /p /p +ONBUILD RUN --mount=type=bind,from=example/tools:6,target=/t true +FROM template AS child +FROM child +"; + assert_eq!( + images(dockerfile), + ["alpine:3.22", "example/plugin:5", "example/tools:6"] + ); + // A trigger in the target stage itself only affects images built from it. + assert_eq!( + images("FROM alpine:3.22\nONBUILD COPY --from=example/plugin:5 /p /p\n"), + ["alpine:3.22"] + ); + for trigger in [ + "ONBUILD FROM alpine:3.22", + "ONBUILD ONBUILD RUN true", + "ONBUILD BOGUS x", + ] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{trigger}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn platform_flags_must_name_arm64_or_the_build_platforms() { + for accepted in [ + "--platform=linux/arm64", + "--platform=linux/arm64/v8", + "--platform=$BUILDPLATFORM", + "--platform=${TARGETPLATFORM}", + ] { + assert_eq!( + images(&format!("FROM {accepted} alpine:3.22\n")), + ["alpine:3.22"], + "{accepted}" + ); + } + assert_eq!( + images("ARG ARCH=arm64\nFROM --platform=linux/${ARCH} alpine:3.22\n"), + ["alpine:3.22"] + ); + for refused in [ + "--platform=linux/amd64", + "--platform=${BUILDPLATFORM:-linux/amd64}", + "--platform=$UNSET", + "--platform", + ] { + assert_eq!( + code(&format!("FROM {refused} alpine:3.22\n")), + "native_build_dockerfile", + "{refused}" + ); + } + // An unreachable stage's platform does not matter. + let dockerfile = "FROM --platform=linux/amd64 alpine:3.22 AS other\nFROM busybox:1.37\n"; + assert_eq!(images(dockerfile), ["busybox:1.37"]); +} + +#[test] +fn overridden_platform_arguments_must_resolve_to_arm64() { + // A global default replaces the frontend's platform value, so the spelling + // alone no longer selects linux/arm64. + for dockerfile in [ + "ARG BUILDPLATFORM=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG TARGETPLATFORM=linux/amd64\nFROM --platform=${TARGETPLATFORM} alpine:3.22\n", + "ARG P=linux/amd64\nARG BUILDPLATFORM=$P\nFROM --platform=${BUILDPLATFORM} alpine:3.22\n", + "ARG BUILDPLATFORM=${OTHER:-linux/arm64}\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG TARGETARCH=amd64\nFROM --platform=linux/$TARGETARCH alpine:3.22\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An override that resolves to linux/arm64 still selects it. + assert_eq!( + images("ARG TARGETPLATFORM=linux/arm64\nFROM --platform=$TARGETPLATFORM alpine:3.22\n"), + ["alpine:3.22"] + ); + // A bare global ARG keeps the frontend's value. + assert_eq!( + images( + "ARG BUILDPLATFORM\nARG TARGETPLATFORM\nFROM --platform=$BUILDPLATFORM alpine:3.22 AS a\nFROM --platform=${TARGETPLATFORM} a\n" + ), + ["alpine:3.22"] + ); +} + +#[test] +fn arg_names_must_be_plain_identifiers() { + // The frontend would unquote, unescape or expand these names, so a literal + // compare could miss an override of BUILDPLATFORM or TARGETPLATFORM. + for dockerfile in [ + "ARG \"BUILDPLATFORM\"=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG 'TARGETPLATFORM'=linux/amd64\nFROM --platform=$TARGETPLATFORM alpine:3.22\n", + "ARG BUILD\\PLATFORM=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG X=BUILDPLATFORM\nARG $X=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG ${X}\nFROM alpine:3.22\n", + "ARG 1V=1\nFROM alpine:3.22\n", + "ARG my-arg\nFROM alpine:3.22\n", + // Stage arguments too. + "FROM alpine:3.22\nARG \"V\"=1\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + assert_eq!( + images("ARG _V2=3.22 v_3\nFROM alpine:$_V2\nARG STAGE_ARG=1\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn expanded_argument_values_are_bounded() { + // 13 doublings make 8 KiB, past the 4 KiB value cap: unresolved, so a reference + // using it refuses as a variable the scanner cannot resolve. + let doubled = |times: usize, from: &str| { + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(times)); + dockerfile.push_str(from); + dockerfile + }; + assert_eq!( + code(&doubled(13, "FROM alpine:$A\n")), + "native_build_dockerfile" + ); + // Under the cap the value still resolves. + let scan = scan_with(&doubled(6, "FROM example/tool:$A\n"), None, &[]).unwrap(); + assert_eq!(names(&scan), [format!("example/tool:{}", "x".repeat(64))]); + // An overridden platform argument past the cap is unresolved and still refuses. + assert_eq!( + code(&doubled( + 13, + "ARG BUILDPLATFORM=$A\nFROM --platform=$BUILDPLATFORM alpine:3.22\n" + )), + "native_build_dockerfile" + ); +} + +#[test] +fn doubling_argument_defaults_stay_bounded() { + // Without a cap, 40 doublings would need a 1 TiB value. + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(40)); + dockerfile.push_str("FROM alpine:3.22\n"); + assert_eq!(images(&dockerfile), ["alpine:3.22"]); + // Distinct copies of a capped value share one 256 KiB budget: the first copies + // resolve (and fail as over-long references), later ones are unresolved. + let mut dockerfile = format!("ARG A={}\n", "x".repeat(4000)); + for index in 0..100 { + dockerfile.push_str(&format!("ARG B{index}=$A\n")); + } + let with = |from: &str| format!("{dockerfile}{from}"); + assert_eq!(code(&with("FROM alpine:$B0\n")), "native_build_base_image"); + assert_eq!(code(&with("FROM alpine:$B99\n")), "native_build_dockerfile"); + assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); +} + +#[test] +fn stage_names_match_case_insensitively_everywhere_the_frontend_looks_them_up() { + let dockerfile = "\ +FROM alpine:3.22 AS Base +RUN true + +FROM BASE AS Tools +COPY --from=Base /etc/os-release /x + +FROM busybox:1.37 AS Final +COPY --from=TOOLS /x /y +RUN --mount=type=bind,from=bAsE,target=/b true +FROM fInAl +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + // `FROM BASE`, `FROM fInAl`, `--from=Base`, `--from=TOOLS` and `from=bAsE` are stages. + assert_eq!(names(&scan), ["alpine:3.22", "busybox:1.37"]); + for target in ["final", "FINAL", "Tools"] { + let scan = scan_with(dockerfile, Some(target), &[]).unwrap(); + assert!(names(&scan).contains(&"alpine:3.22".to_owned()), "{target}"); + } + let tools = scan_with(dockerfile, Some("tools"), &[]).unwrap(); + assert_eq!(names(&tools), ["alpine:3.22"]); + // A name that matches no stage is an image reference, where uppercase is invalid. + assert_eq!(code("FROM Alpine:3.22\n"), "native_build_base_image"); + assert_eq!( + code("FROM alpine:3.22 AS base\nFROM Busybox:1.37\nCOPY --from=Base / /\n"), + "native_build_base_image" + ); + // A stage may only build on an earlier stage, as with the frontend. + assert_eq!( + code("FROM Later AS first\nFROM alpine:3.22 AS later\nFROM first\n"), + "native_build_base_image" + ); +} + +#[test] +fn a_stage_may_not_be_named_scratch() { + // The frontend links `FROM scratch` to an earlier stage of that name, so the + // stage's image and platform would apply. + for dockerfile in [ + "FROM alpine:3.22 AS scratch\nFROM scratch\n", + "FROM --platform=linux/amd64 alpine:3.22 AS Scratch\nFROM scratch\n", + "FROM alpine:3.22 AS SCRATCH\nFROM busybox:1.37\nCOPY --from=scratch / /\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // Plain `scratch` is still the empty image. + assert!(images("FROM scratch\n").is_empty()); + assert_eq!( + images("FROM alpine:3.22 AS build\nFROM scratch\nCOPY --from=build / /\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn frontend_selection_directives_refuse() { + for dockerfile in [ + "# syntax=docker/dockerfile:1\nFROM alpine:3.22\n", + "#SYNTAX = example/frontend\nFROM alpine:3.22\n", + "#!/usr/bin/env buildctl\n# syntax=example/frontend\nFROM alpine:3.22\n", + "// syntax=example/frontend\nFROM alpine:3.22\n", + "# check=error=true\n# syntax=example/frontend\nFROM alpine:3.22\n", + "{\"syntax\": \"example/frontend\"}", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An ordinary comment that mentions syntax later in the file is fine. + assert_eq!( + images("FROM alpine:3.22\n# syntax=example/frontend\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn escape_directive_changes_continuations() { + let dockerfile = "# escape=`\nFROM alpine:3.22 `\n AS base\nRUN echo C:\\path\nFROM base\n"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + assert_eq!( + code("# escape=x\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("# escape=`\n# escape=\\\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + // A directive after an instruction or blank line is only a comment. + assert_eq!( + images("\n# escape=`\nFROM alpine:3.22 \\\n AS base\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn continuations_skip_comments_and_blank_lines() { + let dockerfile = "\ +FROM \\ +# a comment inside the instruction +\x20\x20\x20 + alpine:3.22 \\ + AS base\r +COPY \\ + --from=example/assets:7 \\ + /a /a +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22", "example/assets:7"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); +} + +#[test] +fn heredoc_bodies_are_not_instructions() { + let dockerfile = "\ +FROM alpine:3.22 +RUN < /x && <<\"MORE\" cat > /y +\tFROM example/still-body:1 +\tDONE +FROM example/also-body:2 +MORE +COPY < /x\nFROM scratch\nEOF\n", + "FROM alpine:3.22\nCOPY << 'FILE' /etc/x\nFROM scratch\nFILE\n", + "FROM alpine:3.22\nRUN true <>() + .join("\n"); + assert_eq!(images(&within).len(), MAX_IMAGE_REFERENCES); + + let stages: String = (0..=MAX_STAGES).map(|_| "FROM alpine:3.22\n").collect(); + assert_eq!(code(&stages), "native_build_dockerfile"); +} + +#[test] +fn reserved_build_arguments_refuse_before_reading() { + for name in [ + "BUILDKIT_SYNTAX", + "BUILDKIT_INLINE_CACHE", + "TARGETARCH", + "BUILDPLATFORM", + "TARGETSTAGE", + ] { + let error = scan_with("FROM alpine:3.22\n", None, &[(name, "x")]).unwrap_err(); + assert_eq!(error.code, "native_build_argument", "{name}"); + } +} + +#[test] +fn messages_never_echo_dockerfile_text_or_argument_values() { + let secret = "do-not-echo-7f3a"; + for (dockerfile, args) in [ + (format!("FROM ghcr.io/{secret}/app:1\n"), vec![]), + (format!("ARG V={secret}\nFROM alpine:${{V:-x}}\n"), vec![]), + ( + "ARG V\nFROM ghcr.io/$V:1\n".to_string(), + vec![("V", secret)], + ), + (format!("FROM alpine:3.22\n{secret} instruction\n"), vec![]), + ( + format!("FROM alpine:3.22\nCOPY --from=${secret} / /\n"), + vec![], + ), + ] { + let error = scan_with(&dockerfile, None, &args).unwrap_err(); + assert!(!error.message.contains(secret), "{}", error.message); + assert!(!error.message.contains("ghcr"), "{}", error.message); + } +} diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index f2a876f8b..77774a5ff 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -94,6 +94,7 @@ impl Transport { { return Err(failure("Invalid private engine request path.")); } + require_owned_network(&method, path, body)?; let requires_admission = ![Method::GET, Method::HEAD, Method::DELETE].contains(&method); let mut request = self .client @@ -127,6 +128,40 @@ impl Transport { } } +/// Every container Hack creates names its network explicitly: an owned network, `none` +/// or another container's namespace. Egress pools give the engine a default bridge that +/// only BuildKit `RUN` steps may use (guest-daemon.sh); an unset, `default`, `bridge` +/// or `host` mode, or a connect to the default bridge, would put a container on it or +/// in the guest root namespace, so both refuse before dispatch. +fn require_owned_network( + method: &Method, + path: &str, + body: Option<&Value>, +) -> Result<(), CandidateError> { + if *method != Method::POST { + return Ok(()); + } + let route = path.split('?').next().unwrap_or(path); + let refused = || { + CandidateError::new( + "engine_network_refused", + "Containers must name an owned network or none; the engine default bridge is reserved for image builds.", + ) + }; + if route.ends_with("/containers/create") { + let mode = body + .and_then(|body| body["HostConfig"]["NetworkMode"].as_str()) + .unwrap_or(""); + if mode.is_empty() || ["default", "bridge", "host"].contains(&mode) { + return Err(refused()); + } + } + if route.ends_with("/networks/bridge/connect") { + return Err(refused()); + } + Ok(()) +} + // One minimum budget reaches dispatch. A later explicit deadline cannot replace // an earlier mutation-admission deadline; cleanup admission exemptions stay above. fn request_timeout( @@ -948,6 +983,38 @@ mod tests { use std::io::Write; use std::os::unix::net::UnixListener; + #[test] + fn containers_must_name_their_network_and_never_join_the_build_bridge() { + let create = |mode: Value| { + require_owned_network( + &Method::POST, + "/v1.53/containers/create?name=x", + Some(&serde_json::json!({"HostConfig": {"NetworkMode": mode}})), + ) + }; + for refused in [ + Value::Null, + serde_json::json!(""), + serde_json::json!("default"), + serde_json::json!("bridge"), + serde_json::json!("host"), + serde_json::json!(7), + ] { + assert_eq!(create(refused).unwrap_err().code, "engine_network_refused"); + } + for allowed in ["none", "hack-local-owned", "container:abc"] { + assert!(create(serde_json::json!(allowed)).is_ok()); + } + assert!(require_owned_network(&Method::POST, "/v1.53/containers/create", None).is_err()); + assert!( + require_owned_network(&Method::POST, "/v1.53/networks/bridge/connect", None).is_err() + ); + assert!( + require_owned_network(&Method::POST, "/v1.53/networks/owned/connect", None).is_ok() + ); + assert!(require_owned_network(&Method::GET, "/v1.53/networks/bridge", None).is_ok()); + } + #[test] fn explicit_request_budget_never_extends_mutation_admission() { let now = Instant::now(); diff --git a/packages/runtime-core/src/provider/engine/admission_tests.rs b/packages/runtime-core/src/provider/engine/admission_tests.rs index 3fcf9ad19..a46a489b2 100644 --- a/packages/runtime-core/src/provider/engine/admission_tests.rs +++ b/packages/runtime-core/src/provider/engine/admission_tests.rs @@ -72,6 +72,12 @@ fn empty_admission() -> super::super::managed_environment::Managed { managed } +/// Every create Hack dispatches names its network; the engine guard refuses one that +/// does not before admission is consulted, so these controls name `none`. +fn owned_create() -> Value { + serde_json::json!({"HostConfig": {"NetworkMode": "none"}}) +} + fn expire(deadline: Instant) { while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { std::thread::sleep(remaining + Duration::from_millis(1)); @@ -129,7 +135,11 @@ fn empty_admission_expiring_before_create_sends_no_request() { let transport = fixture.transport(managed.deadline(), Duration::from_millis(250)); expire(managed.deadline()); let failure = transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap_err(); assert_eq!(failure.code, "graph_environment_input"); fixture.assert_no_request(); @@ -183,7 +193,11 @@ fn empty_admission_expiring_after_create_blocks_start_but_allows_owned_delete() None, ); transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap(); create.join().unwrap(); let id = "1".repeat(64); @@ -243,7 +257,11 @@ fn in_flight_mutation_cannot_renew_empty_admission_transport_budget() { // The request was dispatched while valid, but its response arrives after expiry. // Without the remaining-lifetime timeout it would succeed under the 3s default. let failure = transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap_err(); assert_eq!(failure.code, "engine_protocol"); assert!(failure.message.contains("effect may be uncertain")); diff --git a/packages/runtime-core/src/provider/go_json.rs b/packages/runtime-core/src/provider/go_json.rs new file mode 100644 index 000000000..4df91efac --- /dev/null +++ b/packages/runtime-core/src/provider/go_json.rs @@ -0,0 +1,180 @@ +//! JSON objects read the way the engine's Go `encoding/json` reads them. +//! +//! Image configurations come from registries and archives, and the engine and BuildKit +//! decode them into Go structs. Go matches an object key to a struct field by exact +//! name or, failing that, case-insensitively (`bytes.EqualFold`), and when several keys +//! match one field the last one wins. A plain lookup of the canonical key can therefore +//! see a different value than the engine acts on, for example `{"onbuild": [...]}` or +//! `{"architecture": "arm64", "Architecture": "amd64"}`. +//! +//! [`GoJson`] keeps every member, duplicates included, and [`GoJson::field`] returns the +//! one member a Go field would receive. More than one candidate member is ambiguous and +//! refuses. Go's case folding also matches some non-ASCII runes to ASCII letters (for +//! example U+0131, U+017F and U+212A, with rules that differ between Go versions), so an +//! object with any non-ASCII key refuses outright instead of being folded here. A check +//! here therefore never passes a document the engine could read differently. +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; + +/// A JSON document with every object member kept, in order. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum GoJson { + Null, + Bool(bool), + Number(serde_json::Number), + String(String), + Array(Vec), + Object(Vec<(String, GoJson)>), +} + +/// Why an object member cannot be read the way the engine reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Unreadable { + /// More than one member names the same Go struct field. + Ambiguous, + /// A member name contains a non-ASCII character, which Go may fold onto a field. + NonAsciiKey, +} + +/// Whether the ASCII `key` names the ASCII Go field `name`: equal under ASCII case +/// folding. Objects with a non-ASCII key never reach this comparison +/// ([`GoJson::field`] and [`ascii_keys`] refuse them). +pub(super) fn names_field(key: &str, name: &str) -> bool { + key.is_ascii() && key.eq_ignore_ascii_case(name) +} + +/// Refuse an object (any other value passes) with a non-ASCII member name. +pub(super) fn ascii_keys(value: &GoJson) -> Result<(), Unreadable> { + match value { + GoJson::Object(members) if members.iter().any(|(key, _)| !key.is_ascii()) => { + Err(Unreadable::NonAsciiKey) + } + _ => Ok(()), + } +} + +impl GoJson { + pub(super) fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + /// The member a Go struct field named `name` receives: `None` when no key names it + /// (or this is not an object). Refuses when more than one key names it, or when any + /// key of this object is not ASCII. + pub(super) fn field(&self, name: &str) -> Result, Unreadable> { + let Self::Object(members) = self else { + return Ok(None); + }; + ascii_keys(self)?; + let mut found = None; + for (key, value) in members { + if names_field(key, name) { + if found.is_some() { + return Err(Unreadable::Ambiguous); + } + found = Some(value); + } + } + Ok(found) + } + + /// The same document as a [`Value`]. Duplicate keys collapse (last wins), so use + /// this only for a member [`GoJson::field`] already selected unambiguously. + pub(super) fn to_value(&self) -> Value { + match self { + Self::Null => Value::Null, + Self::Bool(value) => Value::Bool(*value), + Self::Number(value) => Value::Number(value.clone()), + Self::String(value) => Value::String(value.clone()), + Self::Array(items) => Value::Array(items.iter().map(Self::to_value).collect()), + Self::Object(members) => Value::Object( + members + .iter() + .map(|(key, value)| (key.clone(), value.to_value())) + .collect::>(), + ), + } + } +} + +impl<'de> Deserialize<'de> for GoJson { + fn deserialize>(deserializer: D) -> Result { + struct Document; + impl<'de> Visitor<'de> for Document { + type Value = GoJson; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a JSON value") + } + fn visit_unit(self) -> Result { + Ok(GoJson::Null) + } + fn visit_bool(self, value: bool) -> Result { + Ok(GoJson::Bool(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(GoJson::Number(value.into())) + } + fn visit_u64(self, value: u64) -> Result { + Ok(GoJson::Number(value.into())) + } + fn visit_f64(self, value: f64) -> Result { + serde_json::Number::from_f64(value) + .map(GoJson::Number) + .ok_or_else(|| E::custom("non-finite number")) + } + fn visit_str(self, value: &str) -> Result { + Ok(GoJson::String(value.to_owned())) + } + fn visit_string(self, value: String) -> Result { + Ok(GoJson::String(value)) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = seq.next_element()? { + items.push(item); + } + Ok(GoJson::Array(items)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut members = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + members.push((key, value)); + } + Ok(GoJson::Object(members)) + } + } + deserializer.deserialize_any(Document) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_names_fold_like_go_and_duplicates_are_ambiguous() { + for key in ["OnBuild", "onbuild", "ONBUILD", "oNbUiLd"] { + assert!(names_field(key, "OnBuild"), "{key}"); + } + for key in ["OnBuilds", "OnBuil", "On_Build", "ÖnBuild", ""] { + assert!(!names_field(key, "OnBuild"), "{key}"); + } + // Go folds some non-ASCII runes onto ASCII letters; such keys are never matched + // here, and an object holding one refuses as a whole. + for key in ["o\u{17f}", "\u{212a}ind", "OnBu\u{131}ld"] { + assert!(!names_field(key, "OnBuild") && !names_field(key, "os")); + let document = GoJson::parse(format!(r#"{{"{key}": 1, "a": 2}}"#).as_bytes()).unwrap(); + assert_eq!(document.field("a"), Err(Unreadable::NonAsciiKey), "{key}"); + } + let document = GoJson::parse(br#"{"a": 1, "os": "linux", "OS": "windows"}"#).unwrap(); + assert_eq!(document.field("os"), Err(Unreadable::Ambiguous)); + let exact = GoJson::parse(br#"{"os": "linux", "os": "linux"}"#).unwrap(); + assert_eq!(exact.field("os"), Err(Unreadable::Ambiguous)); + assert_eq!( + document.field("a").unwrap().map(GoJson::to_value), + Some(serde_json::json!(1)) + ); + assert_eq!(document.field("missing"), Ok(None)); + assert_eq!(GoJson::parse(b"[1]").unwrap().field("a"), Ok(None)); + } +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 68d71de09..c5a3faba4 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -1812,7 +1812,9 @@ pub fn source_compatibility( if needs_source && revision.is_none() { return Err(refused()); } - if !unchanged_normalized_review(&receipt, plan_id, normalized) { + if !unchanged_normalized_review(&receipt, plan_id, normalized) + && !normalized::source_independent_review(&receipt, plan) + { let revision = revision.ok_or_else(refused)?; if hostname_change::prepare(&engine, plan, source_receipt, normalized)?.is_none() { source::prepare_shared_changed( diff --git a/packages/runtime-core/src/provider/graph/normalized.rs b/packages/runtime-core/src/provider/graph/normalized.rs index 7e0e14f11..579fd50b0 100644 --- a/packages/runtime-core/src/provider/graph/normalized.rs +++ b/packages/runtime-core/src/provider/graph/normalized.rs @@ -42,6 +42,42 @@ impl NormalizedInputIdentity { } } +/// Whether a stopped or active graph reads nothing from the project tree: no +/// source binding, dependency cache or cache provenance was recorded for it. +pub(super) fn source_free_receipt(receipt: &Receipt) -> bool { + receipt.source.is_none() + && receipt.initializer_cache_release.is_empty() + && receipt + .resources + .values() + .all(|resource| resource.cache.is_none() && resource.cache_provenance.is_none()) +} + +/// Whether `plan` reviews exactly the retained graph's Compose input for a graph +/// that reads nothing from the project tree. +/// +/// A plan ID also binds the project source inventory, which records the size and +/// modification time of every selected project file. Rewriting the Compose file +/// with its original bytes, or touching any project file, therefore changes the +/// plan ID although the reviewed Compose input is identical. Only graphs that +/// mount project source or use a dependency cache consume that inventory; they +/// keep their shared-source proofs. For every other graph the identical original +/// and normalized Compose bytes are the whole reviewed input. Restore then keeps +/// the retained ownership plan and still requires identical resource bindings, +/// images and readiness, so no compute, data or ownership changes. +pub(super) fn source_independent_review(receipt: &Receipt, plan: &project::PlanData) -> bool { + receipt + .normalized_input + .as_ref() + .is_some_and(|input| input.valid(&receipt.namespace) && input.matches_plan(plan)) + && source_free_receipt(receipt) + && plan.services.values().all(|service| { + !service.active + || (service.dependency_cache.is_none() + && service.mounts.iter().all(|mount| mount.kind == "volume")) + }) +} + pub(super) fn replay_refused() -> CandidateError { error( "graph_normalized_replay", @@ -265,6 +301,114 @@ mod tests { ); } + /// Rewriting the Compose file with its original bytes, or touching any project + /// file, changes only the source inventory. A graph without project source or + /// caches restores from that review; anything that reads source, or any change + /// of the reviewed Compose input itself, keeps the strict proofs. + #[test] + fn source_metadata_change_keeps_a_sourceless_review_restorable() { + let fixture = super::super::tests::Fixture::new(); + let project = fixture.0.join("project"); + fs::create_dir_all(project.join("app")).unwrap(); + fs::write(project.join("app/hello.txt"), b"hello\n").unwrap(); + let candidate = Candidate::discover(&fixture.0).unwrap(); + let namespace = candidate.plan(&project).unwrap().namespace; + let review = |bytes: &[u8]| { + let original = format!("{:x}", Sha256::digest(bytes)); + let report = project::plan_normalized( + &candidate, + project::NormalizedComposeOptions { + branch: None, + project: &project, + compose_file: Path::new("compose.yaml"), + profiles: &[], + expected_namespace: &namespace, + expected_compose_sha256: &original, + compose_bytes: bytes, + }, + ) + .unwrap(); + let identity = NormalizedInputIdentity { + namespace: namespace.clone(), + original_compose_sha256: original.clone(), + normalized_compose_sha256: original, + }; + (report, identity) + }; + let rewrite = |bytes: &[u8]| { + // Some filesystems keep coarse timestamps; make the rewrite observable. + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::write(project.join("compose.yaml"), bytes).unwrap(); + }; + let receipt_for = |plan_id: &str, identity: &NormalizedInputIdentity| -> Receipt { + serde_json::from_value(json!({"version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":namespace,"plan_id":plan_id,"phase":"stopped-data-retained","readiness":{},"resources":{},"normalized_input":identity})).unwrap() + }; + let image = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', volumes: ['data:/data']}}\nvolumes: {data: {}}\n"; + rewrite(image); + let (before, identity) = review(image); + let receipt = receipt_for(&before.plan_id, &identity); + assert!(super::super::unchanged_normalized_review( + &receipt, + &before.plan_id, + &identity + )); + // Edit, refuse, put back: same bytes, new modification time. + rewrite(b"services: {app: {image: alpine}}\n"); + rewrite(image); + let (restored, restored_identity) = review(image); + assert_eq!(restored_identity, identity); + assert_ne!(restored.plan_id, before.plan_id); + assert!(!super::super::unchanged_normalized_review( + &receipt, + &restored.plan_id, + &identity + )); + assert!(source_independent_review(&receipt, &restored.plan)); + // Touching an unrelated project file is the same kind of change. + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::write(project.join("app/hello.txt"), b"hello\n").unwrap(); + let (touched, _) = review(image); + assert_ne!(touched.plan_id, restored.plan_id); + assert!(source_independent_review(&receipt, &touched.plan)); + + // A changed Compose input is not this path (Compose edits are reconciled elsewhere). + let edited = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', environment: {REVISION: '2'}, volumes: ['data:/data']}}\nvolumes: {data: {}}\n"; + rewrite(edited); + let (changed, _) = review(edited); + assert!(!source_independent_review(&receipt, &changed.plan)); + rewrite(image); + + // A graph that recorded source, a cache or cache provenance keeps its proofs. + let mut sourced = receipt.clone(); + sourced.source = Some( + serde_json::from_value(json!({"revision":"e".repeat(64),"archive_sha256":"f".repeat(64),"selection_sha256":"0".repeat(64)})) + .unwrap(), + ); + assert!(!source_free_receipt(&sourced)); + let (current, _) = review(image); + assert!(!source_independent_review(&sourced, ¤t.plan)); + // A foreign namespace or missing provenance never qualifies. + let mut foreign = receipt.clone(); + foreign.namespace = "f".repeat(64); + assert!(!source_independent_review(&foreign, ¤t.plan)); + let mut legacy = receipt.clone(); + legacy.normalized_input = None; + assert!(!source_independent_review(&legacy, ¤t.plan)); + + // A bind mount reads project source: the inventory is part of what it runs. + let bind = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', volumes: ['./app:/app:ro']}}\n"; + rewrite(bind); + let (mounted, mounted_identity) = review(bind); + let mounted_receipt = receipt_for(&mounted.plan_id, &mounted_identity); + rewrite(bind); + let (remounted, _) = review(bind); + assert_ne!(remounted.plan_id, mounted.plan_id); + assert!(!source_independent_review( + &mounted_receipt, + &remounted.plan + )); + } + #[test] fn normalized_receipt_cannot_be_replayed_as_original() { let mut receipt: Receipt = serde_json::from_value(json!({"version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":"c".repeat(64),"plan_id":"d".repeat(64),"phase":"ready","readiness":{},"resources":{}})).unwrap(); diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index 756427a92..2bd5cc14f 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -329,7 +329,14 @@ fn restore_inputs( Some(change), )?; } else { - verify_fresh(&receipt, &inputs.review.plan_id, fresh, &generation)?; + // A source-independent review of the exact retained Compose input + // replays under the retained plan; resource bindings are rechecked below. + let plan = if normalized::source_independent_review(&receipt, &inputs.review.plan) { + &receipt.plan_id + } else { + &inputs.review.plan_id + }; + verify_fresh(&receipt, plan, fresh, &generation)?; } } else { normalized::require_file_replay(&receipt)?; diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 42fd37cc6..7fdbc26ba 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -394,7 +394,11 @@ pub(super) fn prepare_replay( .as_ref() .and_then(|source| source.live.as_ref()); if !live || retained.is_none() { - if live || retained.is_some() || inputs.review.plan_id != receipt.plan_id { + if live + || retained.is_some() + || (inputs.review.plan_id != receipt.plan_id + && !super::normalized::source_independent_review(receipt, &inputs.review.plan)) + { return Err(error( "graph_source_changed", "Replay source mode or immutable review changed.", diff --git a/packages/runtime-core/src/provider/guest-build-network.sh b/packages/runtime-core/src/provider/guest-build-network.sh new file mode 100644 index 000000000..c78c09ed7 --- /dev/null +++ b/packages/runtime-core/src/provider/guest-build-network.sh @@ -0,0 +1,33 @@ +set -eu +# Isolate the build bridge (docker0) that only BuildKit `RUN` steps use. Docker's +# own isolation chains already drop docker0 <-> owned project bridges; these rules +# also drop build traffic to the guest itself (relays, sockets, the engine), to +# the host gateway and to link-local metadata, while allowing DNS to the guest's +# configured resolvers. External reach is then only what the VM boundary allows. +# Arguments: network mode, guest boot ID. The marker binds the rules to this boot. +case "${1-}" in + isolated) exit 0 ;; + approved-hosts|internet|host-gateway) ;; + *) exit 64 ;; +esac +test -d /sys/class/net/docker0 +marker=/run/hack-local/build-network +for chain in HACK-BUILD-FORWARD HACK-BUILD-INPUT; do + iptables -N "$chain" 2>/dev/null || iptables -F "$chain" +done +for resolver in $(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf); do + case "$resolver" in *:*|127.*) continue ;; esac + for protocol in udp tcp; do + iptables -A HACK-BUILD-FORWARD -d "$resolver" -p "$protocol" --dport 53 -j RETURN + iptables -A HACK-BUILD-INPUT -d "$resolver" -p "$protocol" --dport 53 -j ACCEPT + done +done +iptables -A HACK-BUILD-FORWARD -d 100.96.0.1 -j DROP +iptables -A HACK-BUILD-FORWARD -d 169.254.0.0/16 -j DROP +iptables -A HACK-BUILD-INPUT -j DROP +iptables -C DOCKER-USER -i docker0 -j HACK-BUILD-FORWARD 2>/dev/null || + iptables -I DOCKER-USER 1 -i docker0 -j HACK-BUILD-FORWARD +iptables -C INPUT -i docker0 -j HACK-BUILD-INPUT 2>/dev/null || + iptables -I INPUT 1 -i docker0 -j HACK-BUILD-INPUT +printf 'build-network-v1 %s\n' "$2" > "$marker.pending" +mv "$marker.pending" "$marker" diff --git a/packages/runtime-core/src/provider/guest-daemon.sh b/packages/runtime-core/src/provider/guest-daemon.sh index 68fc19f53..017c004af 100644 --- a/packages/runtime-core/src/provider/guest-daemon.sh +++ b/packages/runtime-core/src/provider/guest-daemon.sh @@ -1,12 +1,17 @@ set -eu +# Egress pools give dockerd a default bridge with a fixed range outside Docker's +# default address pools. Only BuildKit `RUN` steps use it: graph containers always +# name an owned network or `none`, and the engine client refuses any other create. +# guest-build-network.sh isolates it after the engine starts. Isolated pools keep +# no default bridge, so builds refuse there. case "${1-}" in - isolated) gateway_rules=false; set -- ;; - approved-hosts|internet) gateway_rules=true; set -- ;; - host-gateway) gateway_rules=true; set -- --host-gateway-ip=100.96.0.1 ;; + isolated) gateway_rules=false; set -- --bridge=none ;; + approved-hosts|internet) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false ;; + host-gateway) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false --host-gateway-ip=100.96.0.1 ;; *) exit 64 ;; esac dockerd --storage-driver=overlay2 --data-root=/var/lib/docker \ - --bridge=none --iptables="$gateway_rules" --ip6tables=false \ + --iptables="$gateway_rules" --ip6tables=false \ --ip-forward="$gateway_rules" --ip-masq="$gateway_rules" "$@" \ --exec-root=/run/hack-local/exec --pidfile=/run/hack-local/docker.pid \ --host=unix:///run/hack-local/docker.sock --log-level=warn \ diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs new file mode 100644 index 000000000..c373931ab --- /dev/null +++ b/packages/runtime-core/src/provider/image_build.rs @@ -0,0 +1,1534 @@ +//! Native Compose `build:` images: `runtime build-image`. +//! +//! One operation builds one image for one build key inside the guest engine, through +//! the pinned host BuildKit client (`docker-buildx`, docker driver) talking to the +//! engine's verified forwarded socket through a private relay ([`client_relay`]) that +//! keeps the provider's socket forwarding out of a known busy loop. The built image +//! lands in the guest image store, +//! where graph admission already looks, and the caller pins the service to the content +//! ID it returns, so review, receipts and restore treat it like a pulled image. +//! +//! Authority and ownership: +//! +//! - The build key binds the local inputs: namespace (project root and branch), context +//! and Dockerfile paths, the Dockerfile and `.dockerignore` bytes, target, salted +//! argument hashes, platform, and the builder and engine versions. Context file +//! contents are not hashed, as with Compose: an existing image is reused until an +//! explicit rebuild. Base-image tags resolve only when a build runs. +//! - Every image the Dockerfile can pull (see [`super::build_scan`]) resolves to a digest +//! before BuildKit runs and is handed to it as a named build context, so BuildKit pulls +//! only those exact manifests. Base images with stored `ONBUILD` triggers refuse. +//! - The provider lease is held only for short admission and adoption steps, never while +//! BuildKit runs. A shared build fence (`run/image-builds/active.lock`), taken under the +//! lease and held for the whole operation, makes `runtime down` refuse instead of +//! stopping the engine under a running build. +//! - A receipt `run/image-builds//.json` records the attempt. Only a +//! `content-id-verified` receipt from the same checkout, provider incarnation and +//! engine, whose image still inspects with exactly the owned labels, is reused. A +//! `building` receipt found under the per-key lock belongs to a dead attempt (the lock +//! is released only when its holder exits): it is recorded `indeterminate` and the +//! image is built again. Build outputs are content addressed and adoption always +//! rechecks labels and incarnation, so no operator action is needed. +//! - Argument values never appear in argv, the client's environment, receipts or +//! messages: the client runs `buildx bake` with a private mode-0600 definition file +//! that carries them, removed after the run, and its environment is cleared to a +//! fixed allowlist. Its output reaches the caller only through [`output::Filter`], +//! which redacts every value and strips terminal control sequences, within fixed +//! bounds. +//! - The client builds from a private copy of the Dockerfile and ignore bytes that were +//! scanned and hashed, never from the project paths again. +//! - Every build runs under [`build_policy::document`] for its pins, a BuildKit source +//! policy the client sends with the solve: only the pinned base references resolve as +//! images, and network sources (`ADD` URLs, git, image blobs) are denied, including +//! ones a base image's stored `ONBUILD` triggers add. +//! - Build arguments are not secrets, as with Docker: an image records their values in +//! its history, and resolved base references (which an argument may select) are +//! reported in receipts and results. Hack still keeps values out of argv, the +//! client's environment and its own messages, and redacts them from forwarded output +//! as defense in depth. The caller refuses values equal to a managed secret. +//! - Nothing here removes images, prunes caches or adopts an image whose labels differ. +use super::{ + artifact, build_policy, + build_scan::{self, BaseImage}, + registry_image::{self, BuildBase}, + state, +}; +use crate::{Candidate, CandidateError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::fd::AsRawFd, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Child, Stdio}, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, Instant}, +}; + +mod client_relay; +mod output; +pub mod reclaim; +#[cfg(test)] +mod tests; + +/// The default bridge range guest-daemon.sh gives egress pools; only builds use it. +pub(super) const BUILD_NETWORK_SUBNET: &str = "10.213.255.0/24"; +const BUILD_NETWORK_MARKER: &str = "build-network-v1"; +/// Repository for every Hack-built image; review never accepts it as an authored image. +pub const BUILD_REPOSITORY: &str = "hack-local-build"; +const KIND: &str = "build-image"; +const DEFAULT_TIMEOUT_SECONDS: u64 = 30 * 60; +const MAX_TIMEOUT_SECONDS: u64 = 2 * 60 * 60; +const CANCEL_GRACE: Duration = Duration::from_secs(10); +const POLL: Duration = Duration::from_millis(50); +const LEASE_WAIT: Duration = Duration::from_secs(5); +const MAX_REQUEST_BYTES: u64 = 192 * 1024; +/// Covers a receipt with the most base pins the scanner accepts at their longest +/// (about 32 KiB pretty-printed), so a receipt the build writes is always readable. +const MAX_RECEIPT_BYTES: u64 = 64 * 1024; +const MAX_METADATA_BYTES: u64 = 1024 * 1024; +const MAX_IGNORE_BYTES: u64 = 1024 * 1024; +const MAX_ARGS: usize = 64; +const MAX_ARG_BYTES: usize = 64 * 1024; +const GIB: u64 = 1024 * 1024 * 1024; +/// A cold build needs this much free guest storage, or twice the key's last verified +/// image size when that is larger. Admission refuses; it never deletes to make room. +const STORAGE_FLOOR: u64 = 6 * GIB; +/// BuildKit cache kept after each successful build. The cache in this engine is created +/// only by Hack builds and is regenerable; pruning never touches images or volumes. +pub(super) const CACHE_BUDGET: u64 = 8 * GIB; +/// Labels a Dockerfile (or a base image) must not set on an image Hack runs: the same +/// reserved ownership namespaces the frontend refuses on authored services. +const RESERVED_LABEL_PREFIXES: [&str; 5] = [ + "com.docker.compose.", + "io.hack-local.", + "io.hack.native-config.", + "io.hack.native-file.", + "io.hack.storage-witness.", +]; + +fn refusal(code: &'static str, message: impl Into) -> CandidateError { + CandidateError::new(code, message) +} +fn invalid(message: impl Into) -> CandidateError { + refusal("native_build_request", message) +} +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn image_id(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(hex64) +} + +/// When the caller wants a fresh build. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Policy { + /// Reuse a verified image for this build key; build only when it is missing. + Missing, + /// Always build (BuildKit's cache keeps unchanged steps cheap) and re-resolve bases. + Rebuild, +} + +/// The private request file. It is read only from a singly linked 0600 file owned by +/// this user, because argument values are in it. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildRequest { + pub version: u32, + /// Exact canonical project root; the namespace is derived from it and `branch`. + pub project_root: PathBuf, + #[serde(default)] + pub branch: Option, + /// Build context, relative to the project root. + pub context: String, + /// Dockerfile, relative to the context. + #[serde(default = "default_dockerfile")] + pub dockerfile: String, + #[serde(default)] + pub target: Option, + /// Literal build arguments. They are not secrets (an image records them in its + /// history), but Hack keeps them out of argv and its own messages. + #[serde(default)] + pub args: BTreeMap, + pub policy: Policy, + #[serde(default)] + pub timeout_seconds: Option, +} +fn default_dockerfile() -> String { + "Dockerfile".into() +} + +/// The result of one `runtime build-image`. +#[derive(Debug, Serialize)] +pub struct BuildOutcome { + pub image_id: String, + pub build_key: String, + pub tag: String, + /// True when a verified image was reused without running the builder. + pub reused: bool, + /// Named build context to the pinned reference it resolved to, for this image. + pub base_pins: BTreeMap, + /// Build cache bytes the post-build prune reclaimed; `None` when no prune ran or + /// it failed (the build result stands either way). + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_reclaimed_bytes: Option, +} + +/// Receipt phases. `building` with no live holder of the key lock is a dead attempt. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Phase { + Building, + ContentIdVerified, + Failed, + Canceled, + Indeterminate, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Receipt { + version: u32, + checkout: PathBuf, + namespace: String, + build_key: String, + provider_incarnation: String, + provider_boot_id: String, + engine_id: String, + phase: Phase, + #[serde(default, skip_serializing_if = "Option::is_none")] + image_id: Option, + /// Engine-reported size of the last verified image of this key, for the next cold + /// build's admission. Attempts that verify no image carry it forward unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + image_bytes: Option, + base_pins: BTreeMap, +} + +/// The pool identity a build is admitted against and must find unchanged at adoption. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PoolIdentity { + pub incarnation: String, + pub boot_id: String, + pub engine_id: String, + /// Host path of the verified forwarded engine socket. + pub socket: PathBuf, +} + +/// The effects a build needs from the provider. The live implementation holds the +/// provider lease inside `Lease`; tests substitute a fake pool and a fake builder. +pub(super) trait BuildPool { + type Lease; + /// Take the provider lease, verify the running pool, its build network and engine. + fn lease(&self) -> Result<(Self::Lease, PoolIdentity), CandidateError>; + /// Inspect one image by content ID or tag; `None` when the engine reports it absent. + fn inspect(&self, lease: &Self::Lease, image: &str) -> Result, CandidateError>; + /// Resolve one base image to its pinned manifest. Network only; no lease is held. + /// Returns, with an error, by `deadline` or soon after `canceled` reports true. + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + canceled: &dyn Fn() -> bool, + ) -> Result; + /// The verified builder executable. + fn builder(&self) -> Result; + /// Where the client's filtered output goes. + fn output(&self) -> Box; + /// Free bytes on the guest storage disk that holds images and build cache. + fn storage_available(&self, lease: &Self::Lease) -> Result; + /// Prune BuildKit cache down to `max_used` bytes; returns the bytes reclaimed. + fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result; +} + +/// Free storage a cold build of a key requires. +pub(super) fn storage_required(previous_image_bytes: Option) -> u64 { + previous_image_bytes + .map_or(0, |bytes| bytes.saturating_mul(2)) + .max(STORAGE_FLOOR) +} + +/// Validated local inputs and the build key derived from them. +/// +/// The Dockerfile and its effective ignore file are held as the exact bytes that were +/// scanned and hashed: the client builds from a private copy of them, never from the +/// mutable project paths, so a file edited while bases resolve cannot reach BuildKit. +#[derive(Debug)] +pub(super) struct Inputs { + namespace: String, + key: String, + context: PathBuf, + dockerfile: Vec, + /// The ignore rules BuildKit applies: the Dockerfile-specific file if present, else + /// the context's `.dockerignore`, else empty (no exclusions). + ignore: Vec, + target: Option, + args: BTreeMap, + images: Vec, + policy: Policy, + timeout: Duration, +} + +impl Inputs { + fn tag(&self) -> String { + format!( + "{BUILD_REPOSITORY}/{}:{}", + &self.namespace[..12], + &self.key[..32] + ) + } +} + +/// The non-secret owner value an image carries: a digest of the pool incarnation, so +/// the incarnation itself never reaches the client's argv. +pub(super) fn owner_label(incarnation: &str) -> String { + sha256_hex(format!("hack-native-build-owner-v1\0{incarnation}").as_bytes())[..32].to_owned() +} + +fn labels(inputs: &Inputs, identity: &PoolIdentity) -> BTreeMap<&'static str, String> { + BTreeMap::from([ + ("io.hack-local.build-key", inputs.key.clone()), + ("io.hack-local.kind", KIND.to_owned()), + ("io.hack-local.namespace", inputs.namespace.clone()), + ("io.hack-local.owner", owner_label(&identity.incarnation)), + ]) +} + +/// The namespace builds for one project root and branch belong to. Worktrees and +/// branches sharing one pool get distinct namespaces, so images never cross them. +pub(super) fn namespace(root: &Path, branch: Option<&str>) -> String { + let mut digest = Sha256::new(); + digest.update(b"hack-native-build-namespace-v1\0"); + digest.update(root.as_os_str().as_encoded_bytes()); + digest.update(b"\0"); + digest.update(branch.unwrap_or("").as_bytes()); + format!("{:x}", digest.finalize()) +} + +/// Build-argument names BuildKit or buildx interpret themselves rather than passing to +/// the Dockerfile: `BUILDKIT_*` (for example `BUILDKIT_SYNTAX` selects a frontend +/// image), `BUILDX_*` and `SOURCE_DATE_EPOCH`. Any case refuses. Other names, including +/// ones a process environment would interpret (`PATH`, `LD_*`, `GO*`, proxies), are +/// ordinary Dockerfile arguments here: values travel only in the private bake +/// definition and never enter the client's environment. +fn reserved_argument(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + upper.starts_with("BUILDKIT_") || upper.starts_with("BUILDX_") || upper == "SOURCE_DATE_EPOCH" +} + +fn valid_argument_name(name: &str) -> bool { + let mut bytes = name.bytes(); + name.len() <= 128 + && bytes + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) +} + +fn valid_branch(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn validate_request(request: &BuildRequest) -> Result<(), CandidateError> { + if request.version != 1 { + return Err(invalid("Unsupported build request version.")); + } + if !request.project_root.is_absolute() { + return Err(invalid("The project root must be an absolute path.")); + } + if request.branch.as_deref().is_some_and(|b| !valid_branch(b)) { + return Err(invalid( + "The branch selector must be a lowercase DNS label.", + )); + } + if request + .target + .as_deref() + .is_some_and(|t| !valid_identifier(t)) + { + return Err(invalid("Invalid build target name.")); + } + if request.args.len() > MAX_ARGS { + return Err(refusal( + "native_build_argument", + "A native build accepts at most 64 arguments.", + )); + } + let mut total = 0_usize; + for (name, value) in &request.args { + if !valid_argument_name(name) { + return Err(refusal( + "native_build_argument", + "Build argument names must match [A-Za-z_][A-Za-z0-9_]* and be at most 128 bytes.", + )); + } + if reserved_argument(name) { + return Err(refusal( + "native_build_argument", + "Build argument names may not be ones BuildKit or buildx interpret themselves (BUILDKIT_*, BUILDX_*, SOURCE_DATE_EPOCH).", + )); + } + if value.bytes().any(|b| b == 0) { + return Err(refusal( + "native_build_argument", + "Build argument values may not contain NUL bytes.", + )); + } + total += name.len() + value.len(); + } + if total > MAX_ARG_BYTES { + return Err(refusal( + "native_build_argument", + "Build arguments exceed 64 KiB in total.", + )); + } + let timeout = request.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + if timeout == 0 || timeout > MAX_TIMEOUT_SECONDS { + return Err(invalid( + "The build timeout must be between 1 and 7200 seconds.", + )); + } + Ok(()) +} + +/// Read one optional bounded regular file without following a final symlink. +fn read_bounded_file(path: &Path, limit: u64) -> Result>, CandidateError> { + let file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => { + return Err(refusal( + "native_build_source", + "A build input is a symlink or cannot be opened.", + )); + } + }; + let metadata = file.metadata().map_err(state::io)?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > limit { + return Err(refusal( + "native_build_source", + "Build inputs must be singly linked regular files within their size limits.", + )); + } + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(state::io)?; + if bytes.len() as u64 > limit { + return Err(refusal( + "native_build_source", + "A build input grew beyond its size limit.", + )); + } + Ok(Some(bytes)) +} + +/// The ignore files BuildKit applies to this context: a Dockerfile-specific +/// `.dockerignore` wins over the context's `.dockerignore`. +struct IgnoreFiles { + context: Option>, + dockerfile: Option>, +} +impl IgnoreFiles { + fn read(context: &Path, dockerfile: &Path) -> Result { + let mut specific = dockerfile.as_os_str().to_owned(); + specific.push(".dockerignore"); + Ok(Self { + context: read_bounded_file(&context.join(".dockerignore"), MAX_IGNORE_BYTES)?, + dockerfile: read_bounded_file(Path::new(&specific), MAX_IGNORE_BYTES)?, + }) + } + fn digests(&self) -> Value { + let digest = |bytes: &Option>| { + bytes + .as_deref() + .map_or(Value::Null, |bytes| Value::String(sha256_hex(bytes))) + }; + serde_json::json!({ + "context": digest(&self.context), + "dockerfile": digest(&self.dockerfile), + }) + } + /// The rules in force. An empty file excludes nothing, exactly like an absent one. + fn effective(self) -> Vec { + self.dockerfile.or(self.context).unwrap_or_default() + } +} + +/// Validate the request, resolve paths inside the project without following symlinks, +/// read and scan the Dockerfile and derive the build key. Pure local reads. +pub(super) fn prepare(request: BuildRequest) -> Result { + validate_request(&request)?; + let root = fs::canonicalize(&request.project_root).map_err(|_| { + refusal( + "native_build_source", + "The project root does not exist or cannot be inspected.", + ) + })?; + if root != request.project_root || !root.is_dir() { + return Err(refusal( + "native_build_source", + "The project root must be an exact canonical directory.", + )); + } + let context = crate::project::resolve_source(&root, &root, &request.context, false)?; + if !fs::symlink_metadata(&context).is_ok_and(|m| m.is_dir()) { + return Err(refusal( + "native_build_source", + "The build context must be an existing project directory.", + )); + } + let dockerfile = crate::project::resolve_source(&root, &context, &request.dockerfile, false)?; + let bytes = read_bounded_file(&dockerfile, build_scan::MAX_DOCKERFILE_BYTES as u64)? + .ok_or_else(|| { + refusal( + "native_build_source", + "The Dockerfile must be an existing regular project file.", + ) + })?; + let scan = build_scan::scan(&build_scan::ScanRequest { + dockerfile: &bytes, + target: request.target.as_deref(), + args: &request.args, + })?; + let namespace = namespace(&root, request.branch.as_deref()); + let relative = |path: &Path, base: &Path| -> String { + path.strip_prefix(base) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + let salted_args: BTreeMap<&str, String> = request + .args + .iter() + .map(|(name, value)| { + let mut digest = Sha256::new(); + digest.update(namespace.as_bytes()); + digest.update(b"\0"); + digest.update(name.as_bytes()); + digest.update(b"\0"); + digest.update(value.as_bytes()); + (name.as_str(), format!("{:x}", digest.finalize())) + }) + .collect(); + let ignore = IgnoreFiles::read(&context, &dockerfile)?; + let key_input = serde_json::json!({ + "v": 1, + "namespace": namespace, + "context": relative(&context, &root), + "dockerfile": relative(&dockerfile, &context), + "dockerfile_sha256": sha256_hex(&bytes), + "dockerignore": ignore.digests(), + "target": request.target, + "args": salted_args, + "platform": "linux/arm64", + "builder": {"buildx": artifact::BUILDER_VERSION, "engine": artifact::ENGINE_VERSION}, + }); + let key = sha256_hex( + &serde_json::to_vec(&key_input).map_err(|_| invalid("Cannot encode the build key."))?, + ); + Ok(Inputs { + namespace, + key, + context, + dockerfile: bytes, + ignore: ignore.effective(), + target: request.target, + args: request.args, + images: scan.images, + policy: request.policy, + timeout: Duration::from_secs(request.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS)), + }) +} + +fn builds_root(candidate: &Candidate) -> PathBuf { + candidate.state_root.join("run/image-builds") +} + +/// A cooperative `flock` on a private lock file, shared or exclusive, never blocking. +/// +/// A `flock` belongs to the open file description, so a child that inherits the +/// descriptor holds the lock for as long as it keeps it open if this process is +/// killed before it drops the lock (see [`inherit_locks`]). +struct Flock(File); +impl Flock { + fn open(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(state::io)?; + let metadata = file.metadata().map_err(state::io)?; + // SAFETY: geteuid has no preconditions. + if !metadata.is_file() + || metadata.nlink() != 1 + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + { + return Err(refusal("foreign_state", "Unsafe image build lock.")); + } + Ok(file) + } + fn try_lock(file: File, operation: libc::c_int) -> Result, CandidateError> { + // SAFETY: `file` is a live owned descriptor; flock retains no pointers. + if unsafe { libc::flock(file.as_raw_fd(), operation | libc::LOCK_NB) } == 0 { + return Ok(Some(Self(file))); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EWOULDBLOCK) { + Ok(None) + } else { + Err(state::io(error)) + } + } +} +impl Drop for Flock { + fn drop(&mut self) { + // An orderly drop releases the lock for every holder of the description, so a + // transient copy in another thread's forked child cannot keep it. Only a + // process that dies without dropping (SIGKILL) leaves the lock with the + // descriptors its build client inherited. + // SAFETY: the descriptor is live until this value drops. + unsafe { + libc::flock(self.0.as_raw_fd(), libc::LOCK_UN); + } + } +} + +/// Refuse to stop the engine while a build operation is admitted. The caller holds the +/// provider lease: a build takes its shared fence only under that lease, so a build +/// cannot start between this check and the caller's effect. +pub(super) fn require_no_active_build(candidate: &Candidate) -> Result<(), CandidateError> { + let path = builds_root(candidate).join("active.lock"); + if !path.try_exists().map_err(state::io)? { + return Ok(()); + } + match Flock::try_lock(Flock::open(&path)?, libc::LOCK_EX)? { + Some(_probe) => Ok(()), + None => Err(refusal( + "provider_busy", + "A native image build is running; let it finish or cancel it, then stop the runtime.", + )), + } +} + +fn read_receipt(path: &Path) -> Result, CandidateError> { + if !path.try_exists().map_err(state::io)? { + return Ok(None); + } + state::read_bounded(path, MAX_RECEIPT_BYTES).map(Some) +} + +fn label_violation(config_labels: &Value, expected: &BTreeMap<&'static str, String>) -> bool { + let labels = match config_labels { + Value::Null => return true, + Value::Object(labels) => labels, + _ => return true, + }; + for (key, value) in expected { + if labels.get(*key).and_then(Value::as_str) != Some(value.as_str()) { + return true; + } + } + labels.keys().any(|key| { + RESERVED_LABEL_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + && !expected.contains_key(key.as_str()) + }) +} + +/// Whether an inspected image is exactly the one this build key owns. +fn owned_image( + inspected: &Value, + id: &str, + expected: &BTreeMap<&'static str, String>, +) -> Result<(), CandidateError> { + if inspected["Id"] != id || inspected["Architecture"] != "arm64" || inspected["Os"] != "linux" { + return Err(refusal( + "native_build_image", + "The built image identity or platform differs from the build result.", + )); + } + if label_violation(&inspected["Config"]["Labels"], expected) { + return Err(refusal( + "native_build_reserved_label", + "The built image lacks its ownership labels or carries other reserved ownership labels (com.docker.compose.*, io.hack-local.*, io.hack.native-config.*, io.hack.native-file.*, io.hack.storage-witness.*).", + )); + } + match &inspected["Config"]["Volumes"] { + Value::Null => Ok(()), + Value::Object(volumes) if volumes.is_empty() => Ok(()), + _ => Err(refusal( + "native_build_image_volumes", + "The built image declares VOLUME paths; native services refuse implicit image volumes.", + )), + } +} + +/// Run one build operation end to end. See the module documentation for the protocol. +/// `canceled` reports a pending cancellation request; it is checked before each +/// unleased step, during base resolution and while the client runs. The time budget +/// starts here: base resolution and the client share it. +pub(super) fn run( + candidate: &Candidate, + pool: &P, + inputs: &Inputs, + canceled: &dyn Fn() -> bool, +) -> Result { + let deadline = Instant::now() + inputs.timeout; + let canceled_early = || { + refusal( + "native_build_canceled", + "The image build was canceled before the build client started; no image was adopted.", + ) + }; + let expired_early = || { + refusal( + "native_build_timeout", + "The image build exceeded its time budget before the build client started; no image was adopted.", + ) + }; + let root = builds_root(candidate); + state::private_directory(&root)?; + let directory = root.join(&inputs.namespace); + let receipt_path = directory.join(format!("{}.json", inputs.key)); + // Step 1, under the lease: fence, key lock and the reuse decision. + let (lease, identity) = pool.lease()?; + let fence = Flock::try_lock(Flock::open(&root.join("active.lock"))?, libc::LOCK_SH)? + .ok_or_else(|| { + refusal( + "provider_busy", + "The runtime is stopping; no build was admitted.", + ) + })?; + state::private_directory(&directory)?; + let key_lock = Flock::try_lock( + Flock::open(&directory.join(format!("{}.lock", inputs.key)))?, + libc::LOCK_EX, + )? + .ok_or_else(|| { + refusal( + "native_build_busy", + "Another operation is building this image; wait for it and retry.", + ) + })?; + // `state::write` publishes through `.pending` and a rename. A process that + // died between the two left that file; the key lock proves no writer is live, so + // the leftover is discarded (the committed receipt, if any, is unaffected). + match fs::remove_file(receipt_path.with_extension("pending")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } + let expected = labels(inputs, &identity); + let previous = read_receipt(&receipt_path)?; + if let Some(previous) = &previous { + if previous.version != 1 + || previous.namespace != inputs.namespace + || previous.build_key != inputs.key + { + return Err(refusal( + "native_build_receipt", + "A build receipt does not match its path; no image was adopted or replaced.", + )); + } + if inputs.policy == Policy::Missing + && previous.phase == Phase::ContentIdVerified + && previous.checkout == candidate.checkout + && previous.provider_incarnation == identity.incarnation + && previous.engine_id == identity.engine_id + { + if let Some(id) = previous.image_id.as_deref().filter(|id| image_id(id)) { + if let Some(inspected) = pool.inspect(&lease, id)? { + if owned_image(&inspected, id, &expected).is_ok() { + if previous.provider_boot_id != identity.boot_id { + let mut current = previous.clone(); + current.provider_boot_id = identity.boot_id.clone(); + state::write(&receipt_path, ¤t)?; + } + return Ok(BuildOutcome { + image_id: id.to_owned(), + build_key: inputs.key.clone(), + tag: inputs.tag(), + reused: true, + base_pins: previous.base_pins.clone(), + cache_reclaimed_bytes: None, + }); + } + } + } + } + if previous.phase == Phase::Building { + // The key lock proves no live holder: that attempt died mid-build. + let mut dead = previous.clone(); + dead.phase = Phase::Indeterminate; + state::write(&receipt_path, &dead)?; + } + } + let previous_bytes = previous.as_ref().and_then(|receipt| receipt.image_bytes); + drop(lease); + + // Step 2, unleased: resolve every base image to a pinned manifest. + let mut pins = BTreeMap::new(); + for base in &inputs.images { + if canceled() { + return Err(canceled_early()); + } + if Instant::now() >= deadline { + return Err(expired_early()); + } + let resolved = pool.resolve(base, deadline, canceled)?; + if !image_id(&resolved.source_digest) + || !resolved + .pinned_reference + .ends_with(&format!("@{}", resolved.source_digest)) + { + return Err(refusal( + "native_build_base_image", + "A build base image did not resolve to a pinned manifest.", + )); + } + pins.insert(base.context_name.clone(), resolved.pinned_reference); + } + let builder = pool.builder()?; + if canceled() { + return Err(canceled_early()); + } + if Instant::now() >= deadline { + return Err(expired_early()); + } + + // Step 3, under the lease: same pool, then record the attempt. + let (lease, current) = pool.lease()?; + if current != identity { + return Err(refusal( + "runtime_changed", + "The runtime changed before the build started; no build was run.", + )); + } + if pool.storage_available(&lease)? < storage_required(previous_bytes) { + return Err(refusal( + "native_build_storage", + "The native runtime's storage disk has too little free space for this build (at least 6 GiB, or twice the last image of this build). Inspect it with `runtime guest-disk-usage` and remove unused images or data; nothing was deleted.", + )); + } + let mut receipt = Receipt { + version: 1, + checkout: candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: identity.incarnation.clone(), + provider_boot_id: identity.boot_id.clone(), + engine_id: identity.engine_id.clone(), + phase: Phase::Building, + image_id: None, + // Failed, canceled or interrupted attempts keep the last verified size, so a + // retry is admitted against it rather than the floor. + image_bytes: previous_bytes, + base_pins: pins.clone(), + }; + state::write(&receipt_path, &receipt)?; + drop(lease); + + // Step 4, unleased: run the client in its own process group. + let scratch = directory.join(format!("{}.client", inputs.key)); + state::private_directory(&scratch)?; + let metadata = scratch.join("metadata.json"); + match fs::remove_file(&metadata) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } + // Neither the definition (it carries argument values) nor the source policy + // outlives the client run. + let _definition = RemoveOnDrop(scratch.join("definition").join(BAKE_DEFINITION)); + let _policy = RemoveOnDrop(scratch.join("definition").join(SOURCE_POLICY)); + // The client reaches the engine only through the relay, which keeps the provider's + // forwarded socket out of the state described in `client_relay`. + let relay = client_relay::ClientRelay::start(&identity.socket)?; + let mut client = command( + &builder, + &scratch, + &metadata, + inputs, + relay.socket(), + &pins, + &expected, + )?; + inherit_locks(&mut client, &[&fence, &key_lock]); + let ended = supervise( + client, + deadline, + canceled, + output::Filter::new(inputs.args.values().map(String::as_str)), + pool.output(), + ); + // The client has exited or was stopped; wait for the guest to end its connections. + relay.finish(client_relay::FINISH_GRACE); + let ended = match ended { + Ok(ended) => ended, + Err(error) => { + receipt.phase = Phase::Indeterminate; + state::write(&receipt_path, &receipt)?; + return Err(error); + } + }; + match ended { + Ended::Exited(true) => {} + Ended::Exited(false) => { + receipt.phase = Phase::Failed; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_failed", + "The image build failed; its output is shown above. No image was adopted.", + )); + } + Ended::Canceled => { + receipt.phase = Phase::Canceled; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_canceled", + "The image build was canceled; no image was adopted.", + )); + } + Ended::TimedOut => { + receipt.phase = Phase::Canceled; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_timeout", + "The image build exceeded its time budget and was stopped; no image was adopted.", + )); + } + } + + // Step 5, under the lease: the same pool and engine, then adopt by content ID. + let (lease, current) = pool.lease()?; + if current != identity { + receipt.phase = Phase::Indeterminate; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "runtime_changed", + "The runtime changed during the build; the result was not adopted.", + )); + } + let adopted = built_image_id(&metadata).and_then(|id| { + let inspected = pool.inspect(&lease, &id)?.ok_or_else(|| { + refusal( + "native_build_image", + "The built image is absent from the engine.", + ) + })?; + owned_image(&inspected, &id, &expected)?; + Ok((id, inspected["Size"].as_u64())) + }); + let (id, image_bytes) = match adopted { + Ok(adopted) => adopted, + Err(error) => { + // A refused or missing result is a failed build; an engine or transport + // error leaves the outcome unknown. + receipt.phase = if error.code.starts_with("native_build_") { + Phase::Failed + } else { + Phase::Indeterminate + }; + state::write(&receipt_path, &receipt)?; + return Err(error); + } + }; + receipt.phase = Phase::ContentIdVerified; + receipt.image_id = Some(id.clone()); + receipt.image_bytes = image_bytes.or(receipt.image_bytes); + state::write(&receipt_path, &receipt)?; + // The verified image is recorded first; a failed prune leaves only extra cache. + let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok(); + drop(lease); + Ok(BuildOutcome { + image_id: id, + build_key: inputs.key.clone(), + tag: inputs.tag(), + reused: false, + base_pins: pins, + cache_reclaimed_bytes, + }) +} + +/// Hold the shared build fence as an admitted build does, for the macOS-only lifecycle +/// test of the silent-guest stop. +#[cfg(all(test, target_os = "macos"))] +pub(super) fn hold_build_fence_for_test(candidate: &Candidate) -> impl Drop { + let root = builds_root(candidate); + state::private_directory(&root).unwrap(); + Flock::try_lock( + Flock::open(&root.join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap() +} + +/// The image config digest the client reports for the built image. +fn built_image_id(metadata: &Path) -> Result { + let bytes = read_bounded_file(metadata, MAX_METADATA_BYTES)?.ok_or_else(|| { + refusal( + "native_build_image", + "The build client reported no image metadata.", + ) + })?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| { + refusal( + "native_build_image", + "The build client wrote malformed image metadata.", + ) + })?; + let id = value[BAKE_TARGET]["containerimage.config.digest"] + .as_str() + .filter(|id| image_id(id)) + .ok_or_else(|| { + refusal( + "native_build_image", + "The build metadata has no image content ID.", + ) + })?; + Ok(id.to_owned()) +} + +/// Write the scanned Dockerfile bytes and the effective ignore rules into a private +/// directory the client reads them from. The client applies `.dockerignore` +/// beside the Dockerfile in preference to the context's `.dockerignore`, so writing the +/// effective rules there (empty when there are none) keeps the hashed ignore semantics +/// without reading either project file again. +fn stage_dockerfile(scratch: &Path, inputs: &Inputs) -> Result { + let directory = scratch.join("dockerfile"); + state::private_directory(&directory)?; + let dockerfile = directory.join("Dockerfile"); + write_private(&dockerfile, &inputs.dockerfile)?; + write_private(&directory.join("Dockerfile.dockerignore"), &inputs.ignore)?; + Ok(dockerfile) +} + +/// Replace one private file with exactly these bytes, never following a symlink. +fn write_private(path: &Path, bytes: &[u8]) -> Result<(), CandidateError> { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(state::io)?; + file.write_all(bytes).map_err(state::io) +} + +/// The one bake target a build defines; its metadata is keyed by this name. +const BAKE_TARGET: &str = "build"; +const BAKE_DEFINITION: &str = "definition.json"; +/// The BuildKit source policy file beside the definition (see [`build_policy`]). +const SOURCE_POLICY: &str = "source-policy.json"; + +/// Removes one private file when dropped, on every exit path. +struct RemoveOnDrop(PathBuf); +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +/// Escape a string for the bake definition. Bake reads JSON definitions as HCL, where +/// `${...}` interpolates (variables, the environment, functions) and `%{...}` is a +/// template directive; `$${` and `%%{` are their literal forms. Every string in the +/// definition passes through here, so no project-supplied text is ever evaluated. +fn bake_literal(text: &str) -> String { + text.replace("${", "$${").replace("%{", "%%{") +} + +fn bake_strings(value: Value) -> Value { + match value { + Value::String(text) => Value::String(bake_literal(&text)), + Value::Array(items) => Value::Array(items.into_iter().map(bake_strings).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (bake_literal(&key), bake_strings(value))) + .collect(), + ), + other => other, + } +} + +/// The private bake definition: everything the build needs, argument values +/// included. It is written mode 0600 in the client's private scratch directory. +fn bake_definition( + inputs: &Inputs, + dockerfile: &Path, + pins: &BTreeMap, + labels: &BTreeMap<&'static str, String>, +) -> Value { + let contexts: BTreeMap<&str, String> = pins + .iter() + .map(|(name, reference)| (name.as_str(), format!("docker-image://{reference}"))) + .collect(); + let mut target = serde_json::json!({ + "context": inputs.context.to_string_lossy(), + "dockerfile": dockerfile.to_string_lossy(), + "args": inputs.args, + "contexts": contexts, + "labels": labels, + "tags": [inputs.tag()], + "platforms": ["linux/arm64"], + "output": ["type=docker"], + }); + if let Some(stage) = &inputs.target { + target["target"] = Value::String(stage.clone()); + } + bake_strings(serde_json::json!({"target": {BAKE_TARGET: target}})) +} + +/// The client command: `buildx bake` with the private definition. Argv carries only +/// paths and fixed flags, and the environment is cleared to a fixed allowlist: argument +/// values travel only in the definition, never in argv or the client's environment. +fn command( + builder: &Path, + scratch: &Path, + metadata: &Path, + inputs: &Inputs, + engine: &Path, + pins: &BTreeMap, + labels: &BTreeMap<&'static str, String>, +) -> Result { + let config = scratch.join("docker-config"); + let buildx = scratch.join("buildx"); + let bake = scratch.join("definition"); + state::private_directory(&config)?; + state::private_directory(&buildx)?; + state::private_directory(&bake)?; + let dockerfile = stage_dockerfile(scratch, inputs)?; + let definition = bake.join(BAKE_DEFINITION); + let encoded = serde_json::to_vec(&bake_definition(inputs, &dockerfile, pins, labels)) + .map_err(|_| invalid("Cannot encode the build definition."))?; + write_private(&definition, &encoded)?; + // The policy allows exactly the pinned references the definition names as + // contexts, and denies every other image and network source. + let policy = bake.join(SOURCE_POLICY); + let policy_bytes = + serde_json::to_vec(&build_policy::document(pins.values().map(String::as_str))?) + .map_err(|_| invalid("Cannot encode the build source policy."))?; + write_private(&policy, &policy_bytes)?; + let mut command = super::process::clean_command(builder); + command + .current_dir(scratch) + .env("HOME", scratch) + .env("TMPDIR", scratch) + .env("DOCKER_HOST", format!("unix://{}", engine.display())) + // An empty private client configuration: no contexts, credential helpers or + // proxies from the user's Docker setup. + .env("DOCKER_CONFIG", &config) + .env("BUILDX_CONFIG", &buildx) + .env("BUILDX_NO_DEFAULT_ATTESTATIONS", "1") + .env("DOCKER_CLI_HINTS", "false") + // The client sends this policy with the solve, so BuildKit evaluates every + // source of the build against it, including ones a base image's ONBUILD + // triggers add. + .env("EXPERIMENTAL_BUILDKIT_SOURCE_POLICY", &policy) + .args([ + "bake", + "--progress=plain", + "--provenance=false", + "--sbom=false", + ]) + .arg("--file") + .arg(&definition) + .arg("--metadata-file") + .arg(metadata) + // Bake requires explicit read access outside its working directory. + .arg("--allow") + .arg(format!("fs.read={}", inputs.context.display())) + .arg("--allow") + .arg(format!("fs.read={}", bake_parent(&dockerfile).display())) + .arg(BAKE_TARGET) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + // Output reaches the caller only through `output::Filter`. + .stderr(Stdio::piped()) + .process_group(0); + Ok(command) +} + +fn bake_parent(path: &Path) -> &Path { + path.parent().unwrap_or(path) +} + +#[derive(Debug, Eq, PartialEq)] +enum Ended { + Exited(bool), + Canceled, + TimedOut, +} + +static CANCEL_REQUESTED: AtomicBool = AtomicBool::new(false); +extern "C" fn request_cancel(_: libc::c_int) { + CANCEL_REQUESTED.store(true, Ordering::Release); +} + +/// SIGINT, SIGTERM and SIGHUP request cancellation for the whole operation; the +/// previous dispositions return when this guard drops. +struct CancelSignals(Vec<(libc::c_int, libc::sigaction)>); +impl CancelSignals { + fn install() -> Result { + CANCEL_REQUESTED.store(false, Ordering::Release); + let mut previous_actions = Vec::new(); + for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] { + // SAFETY: sigaction structs are plain C storage, zero-initialized before use. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: as above; the kernel fills it with the previous disposition. + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = request_cancel as *const () as usize; + // SAFETY: valid writable mask storage. + unsafe { libc::sigemptyset(&mut action.sa_mask) }; + // SAFETY: both pointers reference live storage for the duration of the call. + if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 { + return Err(state::io(std::io::Error::last_os_error())); + } + previous_actions.push((signal, previous)); + } + Ok(Self(previous_actions)) + } +} +impl Drop for CancelSignals { + fn drop(&mut self) { + for (signal, previous) in &self.0 { + // SAFETY: restores a disposition the kernel returned for this signal. + unsafe { + libc::sigaction(*signal, previous, std::ptr::null_mut()); + } + } + } +} + +/// Let the client inherit the build fence and the key lock. If this process is killed +/// while the client runs, both stay held until the client and everything it leaves +/// running exit: `runtime down` keeps refusing, and a later attempt of the same key +/// refuses with `native_build_busy` instead of sharing the surviving client's attempt +/// files. An orderly return (after the client was reaped or stopped) releases them. +fn inherit_locks(command: &mut std::process::Command, locks: &[&Flock]) { + let descriptors: Vec = locks.iter().map(|lock| lock.0.as_raw_fd()).collect(); + // SAFETY: runs in the forked child before exec; fcntl is async-signal-safe and the + // descriptors are live (their owners outlive the spawn). + unsafe { + command.pre_exec(move || { + for &fd in &descriptors { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } +} + +/// Spawn the client and wait for it, honoring cancellation and the time budget. On +/// either, the whole process group gets SIGTERM, then SIGKILL after a grace period. +/// The group is signaled only while its leader is unreaped, so its ID is still ours. +/// The client's stderr passes through `filter` to `sink`; forwarding ends, bounded, +/// before this returns, so nothing from the client follows the operation's result. +fn supervise( + mut command: std::process::Command, + deadline: Instant, + canceled: &dyn Fn() -> bool, + filter: output::Filter, + sink: Box, +) -> Result { + let mut child = command.spawn().map_err(|_| { + refusal( + "native_build_client", + "The pinned build client could not be started.", + ) + })?; + let _forwarder = child + .stderr + .take() + .map(|stderr| output::Forwarder::spawn(stderr, filter, sink)); + loop { + if let Some(status) = child.try_wait().map_err(state::io)? { + return Ok(Ended::Exited(status.success())); + } + let cancel = canceled(); + if cancel || Instant::now() >= deadline { + stop_group(&mut child)?; + return Ok(if cancel { + Ended::Canceled + } else { + Ended::TimedOut + }); + } + std::thread::sleep(POLL); + } +} + +fn stop_group(child: &mut Child) -> Result<(), CandidateError> { + let group = child.id() as libc::pid_t; + // SAFETY: the leader is unreaped, so this process group ID cannot be reused yet. + unsafe { libc::killpg(group, libc::SIGTERM) }; + let grace = Instant::now() + CANCEL_GRACE; + while Instant::now() < grace { + if child.try_wait().map_err(state::io)?.is_some() { + return Ok(()); + } + std::thread::sleep(POLL); + } + // SAFETY: still unreaped (checked above), so the group ID is still ours. + unsafe { libc::killpg(group, libc::SIGKILL) }; + child.wait().map_err(state::io)?; + Ok(()) +} + +/// The live pool: the candidate's running provider and its pinned builder. +struct LivePool<'a> { + candidate: &'a Candidate, +} + +#[cfg(target_os = "macos")] +fn connect(candidate: &Candidate) -> Result, CandidateError> { + super::engine::Engine::connect_until(candidate, Instant::now() + LEASE_WAIT, || false) +} +#[cfg(not(target_os = "macos"))] +fn connect(candidate: &Candidate) -> Result, CandidateError> { + let _ = LEASE_WAIT; + super::engine::Engine::connect(candidate) +} + +/// Require the isolated build bridge this boot set up (guest-daemon.sh and +/// guest-build-network.sh). A pool started by an older Hack has neither; restarting the +/// runtime is the only remedy, and nothing here restarts the engine. +fn verify_build_network(engine: &super::engine::Engine<'_>) -> Result<(), CandidateError> { + if matches!( + engine.guest().network_intent(), + super::NetworkIntent::Isolated + ) { + return Err(refusal( + "native_build_isolated", + "Native image builds need network egress; this runtime was started isolated.", + )); + } + let restart = || { + refusal( + "native_build_runtime_restart", + "This native runtime has no isolated build network; stop the native runtime and run `hack up` again.", + ) + }; + let bridge = match engine.request(reqwest::Method::GET, "/v1.53/networks/bridge", None) { + Ok(bridge) => bridge, + Err(error) if error.code == "engine_not_found" => return Err(restart()), + Err(error) => return Err(error), + }; + if bridge["IPAM"]["Config"][0]["Subnet"] != BUILD_NETWORK_SUBNET + || bridge["Options"]["com.docker.network.bridge.enable_icc"] != "false" + { + return Err(restart()); + } + let boot = engine.guest().boot_id().to_owned(); + let observed = engine + .guest() + .execute( + &format!( + "test \"$(cat /run/hack-local/build-network)\" = \"{BUILD_NETWORK_MARKER} $1\"\n\ + iptables -C DOCKER-USER -i docker0 -j HACK-BUILD-FORWARD\n\ + iptables -C INPUT -i docker0 -j HACK-BUILD-INPUT\n\ + printf 'build-network-ready\\n'" + ), + &[&boot], + None, + ) + .map_err(|_| restart())?; + if observed != "build-network-ready\n" { + return Err(restart()); + } + Ok(()) +} + +impl<'a> BuildPool for LivePool<'a> { + type Lease = super::engine::Engine<'a>; + fn lease(&self) -> Result<(Self::Lease, PoolIdentity), CandidateError> { + let engine = connect(self.candidate)?; + verify_build_network(&engine)?; + let info = engine.request(reqwest::Method::GET, "/v1.53/info", None)?; + let engine_id = info["ID"] + .as_str() + .filter(|id| { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_graphic()) + }) + .ok_or_else(|| refusal("engine_protocol", "The engine reported no usable identity."))? + .to_owned(); + let identity = PoolIdentity { + incarnation: engine.guest().incarnation().to_owned(), + boot_id: engine.guest().boot_id().to_owned(), + engine_id, + socket: engine.guest().engine_socket()?, + }; + Ok((engine, identity)) + } + fn inspect(&self, lease: &Self::Lease, image: &str) -> Result, CandidateError> { + match lease.request( + reqwest::Method::GET, + &format!("/v1.53/images/{image}/json"), + None, + ) { + Ok(value) => Ok(Some(value)), + Err(error) if error.code == "engine_not_found" => Ok(None), + Err(error) => Err(error), + } + } + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + canceled: &dyn Fn() -> bool, + ) -> Result { + let reference = base.reference.clone(); + interruptible( + move || registry_image::resolve_build_base(&reference, deadline), + deadline, + canceled, + ) + } + fn builder(&self) -> Result { + artifact::verify_builder(self.candidate)?; + Ok(artifact::builder_executable(self.candidate)) + } + fn output(&self) -> Box { + Box::new(std::io::stderr()) + } + fn storage_available(&self, lease: &Self::Lease) -> Result { + guest_storage_available(lease) + } + fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result { + prune_build_cache(lease, max_used) + } +} + +/// Free bytes on the guest `/storage` ext4 disk, which holds `/var/lib/docker`. +pub(super) fn guest_storage_available( + engine: &super::engine::Engine<'_>, +) -> Result { + let output = + engine + .guest() + .execute("df -Pk /storage | awk 'NR == 2 { print $4 }'", &[], None)?; + output + .trim() + .parse::() + .ok() + .and_then(|kib| kib.checked_mul(1024)) + .ok_or_else(|| { + refusal( + "guest_protocol", + "The guest reported no usable free-space value.", + ) + }) +} + +/// Prune the engine's BuildKit cache to at most `max_used` bytes. Cache records only: +/// the build-prune endpoint never removes images, containers or volumes, and BuildKit +/// keeps records an active build uses. +pub(super) fn prune_build_cache( + engine: &super::engine::Engine<'_>, + max_used: u64, +) -> Result { + let value = engine.request( + reqwest::Method::POST, + &format!("/v1.53/build/prune?max-used-space={max_used}"), + None, + )?; + value["SpaceReclaimed"].as_u64().ok_or_else(|| { + refusal( + "engine_protocol", + "The engine reported no reclaimed build-cache size.", + ) + }) +} + +/// Run a blocking, effect-free network step on its own thread and wait for it until +/// `deadline` or a cancellation request. An abandoned step ends by its own deadline; +/// its result is discarded and it changes no local state. +fn interruptible( + step: impl FnOnce() -> Result + Send + 'static, + deadline: Instant, + canceled: &dyn Fn() -> bool, +) -> Result { + let (sender, result) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(step()); + }); + loop { + match result.recv_timeout(POLL) { + Ok(outcome) => return outcome, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(refusal( + "native_build_base_image", + "Base-image resolution ended without a result.", + )); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + if canceled() { + return Err(refusal( + "native_build_canceled", + "The image build was canceled before the build client started; no image was adopted.", + )); + } + if Instant::now() >= deadline { + return Err(refusal( + "native_build_timeout", + "The image build exceeded its time budget before the build client started; no image was adopted.", + )); + } + } +} + +/// `runtime build-image --request `: build or reuse one native image. +pub fn build_image(candidate: &Candidate, request: &Path) -> Result { + let request: BuildRequest = state::read_bounded(request, MAX_REQUEST_BYTES)?; + let inputs = prepare(request)?; + let _signals = CancelSignals::install()?; + run(candidate, &LivePool { candidate }, &inputs, &|| { + CANCEL_REQUESTED.load(Ordering::Acquire) + }) +} diff --git a/packages/runtime-core/src/provider/image_build/client_relay.rs b/packages/runtime-core/src/provider/image_build/client_relay.rs new file mode 100644 index 000000000..99747031c --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/client_relay.rs @@ -0,0 +1,600 @@ +//! A private host relay between the build client and the provider's forwarded engine +//! socket. +//! +//! The pinned provider forwards the guest engine socket to the host through its vsock +//! muxer. On macOS that muxer can enter a permanent busy loop when the host peer of a +//! forwarded connection goes away while the guest is still sending on it: the send fails, +//! the stream is reset, a later credit update from the guest re-arms it with only write +//! interest, and deleting that interest fails (one `kevent` change list stops at the +//! unregistered read filter). The dead socket then reports a hang-up on every wait, each +//! one queues another reset to the guest and restarts the stream's reaper delay, so the +//! stream is never removed. The guest's vCPUs and the shared vsock queue stay saturated +//! and every later agent or engine request times out. +//! +//! The BuildKit client closes many connections before reading the whole response, and +//! closes the rest at exit, so builds reached that state in most runs. The relay keeps the +//! provider-side connection away from it with three rules: +//! +//! - Provider bytes are always read promptly, including after the client has gone (they +//! are then counted and discarded), so the provider never sees a failed send. +//! - The client's end of stream reaches the provider only as a half-close, after every +//! byte the client sent was delivered. +//! - The relay closes a provider-side connection only after the provider reported end of +//! stream, which means the guest side finished. [`ClientRelay::finish`] bounds that wait +//! and then closes what is left, counting it in [`RelayReport::forced`]. +//! +//! The listening socket lives in a fresh mode-0700 directory under the system temporary +//! root, so its path fits `sockaddr_un` and only this user can connect. It accepts only +//! while the client runs; nothing else is relayed and no bytes are inspected or logged. +use crate::CandidateError; +use std::{ + fs::{self, File}, + io::{ErrorKind, Read, Write}, + os::fd::AsRawFd, + os::unix::{ + fs::{DirBuilderExt, MetadataExt}, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +/// How long [`ClientRelay::finish`] waits for the guest to end its connections. +pub(super) const FINISH_GRACE: Duration = Duration::from_secs(10); +const TICK: Duration = Duration::from_millis(50); +const CHUNK: usize = 64 * 1024; +/// Client bytes not yet delivered; reading the client pauses above this. +const TO_PROVIDER_LIMIT: usize = 1024 * 1024; +/// Provider bytes not yet delivered to a live client; reading the provider pauses above +/// this only while the client is still connected but not reading. +const TO_CLIENT_LIMIT: usize = 16 * 1024 * 1024; +/// A larger receive buffer lets the provider's sends complete at once instead of +/// queueing in the provider. +const PROVIDER_RECEIVE_BUFFER: libc::c_int = 4 * 1024 * 1024; +const SOCKET_NAME: &str = "engine.sock"; + +/// What the relay did, for tests and diagnostics. It carries no payload bytes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct RelayReport { + /// Client connections accepted. + pub connections: u32, + /// Provider bytes read after the client had gone. + pub discarded_bytes: u64, + /// Connections closed by the bound in `finish` before the provider ended them. + pub forced: u32, + /// Client connections dropped because the provider socket refused the connection. + pub refused: u32, +} + +struct Shared { + stop: AtomicBool, + force: AtomicBool, + report: Mutex, + connections: Mutex>>, +} + +impl Shared { + fn update(&self, change: impl FnOnce(&mut RelayReport)) { + // A poisoned lock means a connection thread panicked; the counts stay usable. + let mut report = self + .report + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + change(&mut report); + } +} + +/// A running relay. Call [`ClientRelay::finish`] once the client has exited. +pub(super) struct ClientRelay { + directory: PathBuf, + socket: PathBuf, + shared: Arc, + acceptor: Option>, +} + +impl ClientRelay { + /// Listen on a fresh private socket and relay each connection to `provider`. + /// Connecting to `provider` happens per client connection, so starting never touches + /// the engine. + pub(super) fn start(provider: &Path) -> Result { + Self::start_in(temporary_root(), provider) + } + + /// [`Self::start`] with its private directory (and the sweep of abandoned ones) under + /// `root` instead of the temporary directory; tests give each relay its own root. + fn start_in(root: &Path, provider: &Path) -> Result { + let directory = private_directory(root)?; + let socket = directory.join(SOCKET_NAME); + let shared = Arc::new(Shared { + stop: AtomicBool::new(false), + force: AtomicBool::new(false), + report: Mutex::new(RelayReport::default()), + connections: Mutex::new(Vec::new()), + }); + // From here on, dropping `relay` removes the socket and its directory. + let mut relay = Self { + directory, + socket, + shared, + acceptor: None, + }; + let listener = UnixListener::bind(&relay.socket) + .and_then(|listener| listener.set_nonblocking(true).map(|()| listener)) + .map_err(|_| failure("Cannot listen for the build client."))?; + let provider = provider.to_path_buf(); + let accepting = Arc::clone(&relay.shared); + relay.acceptor = Some( + std::thread::Builder::new() + .name("build-client-relay".into()) + .spawn(move || accept(&listener, &provider, &accepting)) + .map_err(|_| failure("Cannot start the build client relay."))?, + ); + Ok(relay) + } + + /// The socket the client connects to. + pub(super) fn socket(&self) -> &Path { + &self.socket + } + + /// Stop accepting, then wait up to `grace` for the provider to end every relayed + /// connection, closing any that remain after it. + pub(super) fn finish(mut self, grace: Duration) -> RelayReport { + self.stop(grace) + } + + fn stop(&mut self, grace: Duration) -> RelayReport { + self.shared.stop.store(true, Ordering::Release); + if let Some(acceptor) = self.acceptor.take() { + let _ = acceptor.join(); + } + let _ = fs::remove_file(&self.socket); + let _ = fs::remove_dir(&self.directory); + let connections = std::mem::take( + &mut *self + .shared + .connections + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ); + let deadline = Instant::now() + grace; + while connections.iter().any(|handle| !handle.is_finished()) && Instant::now() < deadline { + std::thread::sleep(TICK); + } + self.shared.force.store(true, Ordering::Release); + for handle in connections { + let _ = handle.join(); + } + *self + .shared + .report + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl Drop for ClientRelay { + fn drop(&mut self) { + if self.acceptor.is_some() { + // Only an early return reaches this; the client has already been stopped. + self.stop(FINISH_GRACE); + } else { + let _ = fs::remove_file(&self.socket); + let _ = fs::remove_dir(&self.directory); + } + } +} + +fn failure(message: impl Into) -> CandidateError { + CandidateError::new("native_build_client", message) +} + +const DIRECTORY_PREFIX: &str = "hkb-"; + +fn temporary_root() -> &'static Path { + if cfg!(target_os = "macos") { + Path::new("/private/tmp") + } else { + Path::new("/tmp") + } +} + +/// A new mode-0700 directory with a short path under `parent`, owned by this user. Its +/// name carries this process's PID, so a later relay can remove what a killed process +/// left behind. +fn private_directory(parent: &Path) -> Result { + remove_abandoned(parent); + for _ in 0..8 { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .and_then(|mut random| random.read_exact(&mut bytes)) + .map_err(|_| failure("Cannot choose a private build client socket."))?; + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let directory = parent.join(format!("{DIRECTORY_PREFIX}{}-{name}", std::process::id())); + match fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(_) => return Err(failure("Cannot create a private build client socket.")), + } + let metadata = fs::symlink_metadata(&directory) + .map_err(|_| failure("Cannot inspect the private build client socket."))?; + // SAFETY: geteuid has no preconditions. + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(failure( + "The private build client socket directory is not private.", + )); + } + return Ok(directory); + } + Err(failure("Cannot create a private build client socket.")) +} + +/// Remove relay directories whose creating process is gone (it was killed before it +/// could clean up). Only a mode-0700 directory owned by this user, named for a PID that +/// no longer exists and holding nothing but the relay socket, is removed; anything else +/// is left alone. A reused PID only keeps a stale directory longer. +fn remove_abandoned(parent: &Path) { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + // SAFETY: geteuid has no preconditions. + let user = unsafe { libc::geteuid() }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name + .to_str() + .and_then(|name| name.strip_prefix(DIRECTORY_PREFIX)) + .and_then(|rest| rest.split_once('-')) + .and_then(|(pid, _)| pid.parse::().ok()) + .filter(|pid| *pid > 0) + else { + continue; + }; + // SAFETY: signal 0 only checks for existence; no signal is delivered. + let gone = unsafe { libc::kill(pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + let directory = entry.path(); + let private = fs::symlink_metadata(&directory).is_ok_and(|metadata| { + metadata.is_dir() && metadata.uid() == user && metadata.mode() & 0o777 == 0o700 + }); + if !gone || !private { + continue; + } + let socket = directory.join(SOCKET_NAME); + if fs::symlink_metadata(&socket).is_ok_and(|metadata| { + use std::os::unix::fs::FileTypeExt; + metadata.file_type().is_socket() && metadata.uid() == user + }) { + let _ = fs::remove_file(&socket); + } + // Fails, and keeps the directory, unless it is now empty. + let _ = fs::remove_dir(&directory); + } +} + +fn accept(listener: &UnixListener, provider: &Path, shared: &Arc) { + while !shared.stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((client, _)) => { + shared.update(|report| report.connections += 1); + let Ok(upstream) = connect(provider) else { + shared.update(|report| report.refused += 1); + continue; + }; + let relaying = Arc::clone(shared); + let spawned = std::thread::Builder::new() + .name("build-client-connection".into()) + .spawn(move || { + if let Ok(mut connection) = Connection::new(client, upstream) { + connection.run(&relaying); + } + }); + if let Ok(handle) = spawned { + shared + .connections + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(handle); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => std::thread::sleep(TICK), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => std::thread::sleep(TICK), + } + } +} + +fn connect(provider: &Path) -> std::io::Result { + let stream = UnixStream::connect(provider)?; + // SAFETY: the stream owns this live descriptor for the call; `size` points to a + // readable native int of the option's size. A refused size only loses headroom. + unsafe { + libc::setsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVBUF, + (&PROVIDER_RECEIVE_BUFFER as *const libc::c_int).cast(), + std::mem::size_of::() as libc::socklen_t, + ); + } + Ok(stream) +} + +/// Pending bytes for one direction. Storage follows the bytes still pending, not the +/// traffic: the delivered prefix is dropped before it outgrows what remains, so the +/// buffer stays within about twice the pending bytes plus one read. +#[derive(Default)] +struct Pending { + bytes: Vec, + start: usize, +} + +impl Pending { + fn len(&self) -> usize { + self.bytes.len() - self.start + } + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn push(&mut self, bytes: &[u8]) { + // Dropping the delivered prefix once it is at least half the buffer moves each + // byte at most once more, so the cost stays linear in the traffic. + if self.start > 0 && self.start >= self.bytes.len() / 2 { + self.bytes.drain(..self.start); + self.start = 0; + } + self.bytes.extend_from_slice(bytes); + } + fn clear(&mut self) { + self.bytes.clear(); + self.start = 0; + } + /// Write what `stream` accepts now. `Err` means the peer can take no more. + fn flush(&mut self, stream: &mut impl Write) -> Result<(), ()> { + while !self.is_empty() { + match stream.write(&self.bytes[self.start..]) { + Ok(0) => return Err(()), + Ok(count) => self.start += count, + Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => return Err(()), + } + } + if self.start == self.bytes.len() { + self.clear(); + } + Ok(()) + } +} + +enum Received { + Data(usize), + End, + Again, +} + +fn read_some(stream: &mut UnixStream, buffer: &mut [u8]) -> Received { + loop { + match stream.read(buffer) { + Ok(0) => return Received::End, + Ok(count) => return Received::Data(count), + Err(error) if error.kind() == ErrorKind::WouldBlock => return Received::Again, + Err(error) if error.kind() == ErrorKind::Interrupted => {} + // A reset ends the stream like an orderly end of stream. + Err(_) => return Received::End, + } + } +} + +/// One relayed connection. `client_*` is the build client's side, `provider_*` the +/// provider's forwarded engine socket. +struct Connection { + client: UnixStream, + provider: UnixStream, + to_provider: Pending, + to_client: Pending, + /// The client sent end of stream or can no longer be read. + client_ended: bool, + /// The client can no longer receive; provider bytes are discarded. + client_gone: bool, + /// The provider's end of stream was forwarded to the client. + client_closed_write: bool, + /// The provider reported end of stream: the guest side finished sending. + provider_ended: bool, + /// The provider can no longer receive. + provider_gone: bool, + /// The client's end of stream was forwarded to the provider. + provider_closed_write: bool, + /// Provider bytes that could not reach the client because it had gone. + discarded: u64, +} + +impl Connection { + fn new(client: UnixStream, provider: UnixStream) -> std::io::Result { + client.set_nonblocking(true)?; + provider.set_nonblocking(true)?; + Ok(Self { + client, + provider, + to_provider: Pending::default(), + to_client: Pending::default(), + client_ended: false, + client_gone: false, + client_closed_write: false, + provider_ended: false, + provider_gone: false, + provider_closed_write: false, + discarded: 0, + }) + } + + fn done(&self) -> bool { + self.provider_ended + && (self.to_provider.is_empty() || self.provider_gone) + && (self.to_client.is_empty() || self.client_gone) + } + + fn run(&mut self, shared: &Shared) { + let forced = self.relay(shared); + let discarded = self.discarded; + shared.update(|report| { + report.discarded_bytes += discarded; + report.forced += u32::from(forced); + }); + } + + /// Relay until the connection is done; `true` when `finish` closed it first. + fn relay(&mut self, shared: &Shared) -> bool { + let mut buffer = vec![0_u8; CHUNK]; + while !self.done() { + if shared.force.load(Ordering::Acquire) { + return true; + } + let client_events = self.client_events(); + let provider_events = self.provider_events(); + let mut descriptors = [ + libc::pollfd { + // poll ignores a negative descriptor, so an idle side cannot wake it. + fd: if client_events == 0 { + -1 + } else { + self.client.as_raw_fd() + }, + events: client_events, + revents: 0, + }, + libc::pollfd { + fd: if provider_events == 0 { + -1 + } else { + self.provider.as_raw_fd() + }, + events: provider_events, + revents: 0, + }, + ]; + // SAFETY: `descriptors` is live, writable storage for two entries, and both + // non-negative descriptors are owned by this connection for the call. + let ready = unsafe { + libc::poll( + descriptors.as_mut_ptr(), + descriptors.len() as libc::nfds_t, + TICK.as_millis() as libc::c_int, + ) + }; + if ready <= 0 { + continue; + } + let woke = |entry: &libc::pollfd, wanted: libc::c_short| { + entry.revents & (wanted | libc::POLLHUP | libc::POLLERR) != 0 + }; + if woke(&descriptors[1], libc::POLLIN) && !self.provider_ended { + self.read_provider(&mut buffer); + } + if woke(&descriptors[0], libc::POLLIN) && !self.client_ended { + self.read_client(&mut buffer); + } + if woke(&descriptors[1], libc::POLLOUT) + && !self.provider_gone + && self.to_provider.flush(&mut self.provider).is_err() + { + self.provider_gone = true; + self.to_provider.clear(); + } + if woke(&descriptors[0], libc::POLLOUT) && !self.client_gone { + self.flush_client(); + } + self.forward_ends(); + } + false + } + + fn client_events(&self) -> libc::c_short { + let mut events = 0; + if !self.client_ended && self.to_provider.len() < TO_PROVIDER_LIMIT { + events |= libc::POLLIN; + } + if !self.client_gone && !self.to_client.is_empty() { + events |= libc::POLLOUT; + } + events + } + + fn provider_events(&self) -> libc::c_short { + let mut events = 0; + // Provider bytes are read whenever they can be kept or discarded: this is what + // keeps the provider's sends from failing or queueing. + if !self.provider_ended && (self.client_gone || self.to_client.len() < TO_CLIENT_LIMIT) { + events |= libc::POLLIN; + } + if !self.provider_gone && !self.to_provider.is_empty() { + events |= libc::POLLOUT; + } + events + } + + fn read_provider(&mut self, buffer: &mut [u8]) { + match read_some(&mut self.provider, buffer) { + Received::Data(count) if self.client_gone => self.discarded += count as u64, + Received::Data(count) => { + self.to_client.push(&buffer[..count]); + self.flush_client(); + } + Received::End => self.provider_ended = true, + Received::Again => {} + } + } + + fn read_client(&mut self, buffer: &mut [u8]) { + match read_some(&mut self.client, buffer) { + Received::Data(count) => { + self.to_provider.push(&buffer[..count]); + if !self.provider_gone && self.to_provider.flush(&mut self.provider).is_err() { + self.provider_gone = true; + self.to_provider.clear(); + } + } + Received::End => self.client_ended = true, + Received::Again => {} + } + } + + fn flush_client(&mut self) { + if self.to_client.flush(&mut self.client).is_err() { + self.client_gone = true; + self.client_ended = true; + self.discarded += self.to_client.len() as u64; + self.to_client.clear(); + } + } + + /// Forward each side's end of stream as a half-close once its bytes were delivered. + fn forward_ends(&mut self) { + if self.client_ended + && !self.provider_closed_write + && (self.to_provider.is_empty() || self.provider_gone) + { + self.provider_closed_write = true; + let _ = self.provider.shutdown(std::net::Shutdown::Write); + } + if self.provider_ended + && !self.client_closed_write + && !self.client_gone + && self.to_client.is_empty() + { + self.client_closed_write = true; + let _ = self.client.shutdown(std::net::Shutdown::Write); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs new file mode 100644 index 000000000..9c6acafd0 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -0,0 +1,279 @@ +//! The relay against a fake provider socket. The fake provider plays the provider's side +//! of the hazard: it keeps sending after the client left and checks that the relay +//! neither fails those sends nor closes first. +use super::*; +use std::{net::Shutdown, os::unix::fs::FileTypeExt}; + +/// A fake provider socket in its own short private directory. +struct Provider { + directory: PathBuf, + listener: UnixListener, +} +impl Provider { + fn new() -> Self { + let directory = private_directory(temporary_root()).unwrap(); + let listener = UnixListener::bind(directory.join("provider.sock")).unwrap(); + Self { + directory, + listener, + } + } + fn path(&self) -> PathBuf { + self.directory.join("provider.sock") + } + fn accept(&self) -> UnixStream { + let (stream, _) = self.listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream + } +} +impl Drop for Provider { + fn drop(&mut self) { + let _ = fs::remove_file(self.path()); + let _ = fs::remove_dir(&self.directory); + } +} + +fn read_to_end(stream: &mut UnixStream) -> Vec { + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).unwrap(); + bytes +} + +#[test] +fn bytes_pass_both_ways_and_each_end_arrives_as_a_half_close() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut upstream = provider.accept(); + client.write_all(b"GET /_ping HTTP/1.1\r\n\r\n").unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + // The client's end of stream reaches the provider after its bytes, as a half-close. + assert_eq!(read_to_end(&mut upstream), b"GET /_ping HTTP/1.1\r\n\r\n"); + // The provider can still answer on the half-closed connection. + upstream.write_all(b"HTTP/1.1 200 OK\r\n\r\nOK").unwrap(); + upstream.shutdown(Shutdown::Write).unwrap(); + assert_eq!(read_to_end(&mut client), b"HTTP/1.1 200 OK\r\n\r\nOK"); + let report = relay.finish(FINISH_GRACE); + assert_eq!( + report, + RelayReport { + connections: 1, + ..RelayReport::default() + } + ); +} + +#[test] +fn provider_bytes_after_the_client_left_are_drained_and_the_provider_ends_the_connection() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + let mut upstream = provider.accept(); + client.write_all(b"request").unwrap(); + // The client leaves before reading any response, as the build client does. + drop(client); + assert_eq!(read_to_end(&mut upstream), b"request"); + // Every later provider send succeeds: the relay reads and discards it. + upstream + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let chunk = vec![7_u8; 256 * 1024]; + for _ in 0..16 { + upstream.write_all(&chunk).unwrap(); + } + // Later still, the relay has not closed the provider side: sends keep succeeding. + std::thread::sleep(Duration::from_millis(200)); + upstream.write_all(&chunk).unwrap(); + // Only after the provider ends its side does the relay close. + upstream.shutdown(Shutdown::Write).unwrap(); + assert!(read_to_end(&mut upstream).is_empty()); + let report = relay.finish(FINISH_GRACE); + assert_eq!(report.discarded_bytes, 17 * 256 * 1024); + assert_eq!(report.forced, 0); +} + +#[test] +fn finish_closes_connections_the_provider_never_ends_after_its_bound() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let client = UnixStream::connect(relay.socket()).unwrap(); + let mut upstream = provider.accept(); + drop(client); + assert!(read_to_end(&mut upstream).is_empty()); + let started = Instant::now(); + let report = relay.finish(Duration::from_millis(300)); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(report.forced, 1); + // The forced close reaches the provider. + assert!(read_to_end(&mut upstream).is_empty()); +} + +#[test] +fn a_refused_provider_connection_ends_only_that_client_connection() { + let provider = Provider::new(); + let path = provider.path(); + drop(provider); + let relay = ClientRelay::start(&path).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + // The relay may already have closed this connection (its provider connect failed at + // once); macOS then refuses socket options with EINVAL. That is the outcome under + // test, and the read below returns at once. + match client.set_read_timeout(Some(Duration::from_secs(10))) { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(libc::EINVAL) => {} + Err(error) => panic!("unexpected socket option failure: {error}"), + } + let mut bytes = Vec::new(); + // The client sees the connection end, as with an absent engine socket. + let _ = client.read_to_end(&mut bytes); + assert!(bytes.is_empty()); + let report = relay.finish(FINISH_GRACE); + assert_eq!((report.connections, report.refused), (1, 1)); +} + +#[test] +fn the_listening_socket_is_short_private_and_removed_when_the_relay_finishes() { + let relay = ClientRelay::start(Path::new("/nonexistent/docker.sock")).unwrap(); + let socket = relay.socket().to_path_buf(); + // sockaddr_un holds 104 bytes on macOS, including the terminator. + assert!(socket.as_os_str().len() < 104); + let directory = socket.parent().unwrap().to_path_buf(); + let metadata = fs::symlink_metadata(&directory).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o700); + // SAFETY: geteuid has no preconditions. + assert_eq!(metadata.uid(), unsafe { libc::geteuid() }); + assert!( + fs::symlink_metadata(&socket) + .unwrap() + .file_type() + .is_socket() + ); + assert_eq!(relay.finish(FINISH_GRACE), RelayReport::default()); + assert!(!directory.exists()); +} + +#[test] +fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { + // A process that has exited: its PID names the abandoned directory. + let mut exited = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + let dead = exited.id(); + exited.wait().unwrap(); + let mut running = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(); + // Fixtures live under this test's own root, which no other relay sweeps (concurrent + // tests sweep the temporary directory itself), so only the relay started below can + // remove or keep them. + let root = FixtureRoot::new(); + let make = |pid: u32, other: bool| { + let directory = root.0.join(format!("{DIRECTORY_PREFIX}{pid}-0123456789ab")); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + if other { + fs::write(directory.join("other"), "x").unwrap(); + } + drop(UnixListener::bind(directory.join(SOCKET_NAME)).unwrap()); + directory + }; + let abandoned = make(dead, false); + let live = make(running.id(), false); + // A directory holding anything besides the socket is never removed. + let kept = make(dead + 1_000_000, true); + assert!(abandoned.join(SOCKET_NAME).exists()); + let relay = ClientRelay::start_in(&root.0, Path::new("/nonexistent/docker.sock")).unwrap(); + assert!(relay.socket().starts_with(&root.0)); + assert!(!abandoned.exists()); + assert!(live.join(SOCKET_NAME).exists()); + assert!(kept.join("other").exists()); + relay.finish(FINISH_GRACE); + running.kill().unwrap(); + running.wait().unwrap(); +} + +/// A fresh private directory for one test's relay fixtures, removed when dropped. Its name +/// does not carry the relay prefix, so no relay sweep ever matches it. +struct FixtureRoot(PathBuf); +impl FixtureRoot { + fn new() -> Self { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .and_then(|mut random| random.read_exact(&mut bytes)) + .unwrap(); + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let root = temporary_root().join(format!("hkt-{}-{name}", std::process::id())); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + Self(root) + } +} +impl Drop for FixtureRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// A peer that takes a little at a time and then reports a full buffer, checking that the +/// bytes arrive in order. +struct SlowPeer { + received: u64, + calls: u64, +} +impl Write for SlowPeer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.calls += 1; + if self.calls % 3 == 0 { + return Err(ErrorKind::WouldBlock.into()); + } + let count = bytes.len().min(1500); + for byte in &bytes[..count] { + assert_eq!(*byte, (self.received % 251) as u8, "bytes out of order"); + self.received += 1; + } + Ok(count) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[test] +fn pending_storage_follows_the_queued_bytes_not_the_whole_transfer() { + // Sustained backpressure: the queue is topped up to its limit while a slow peer + // takes partial writes, for 128 times the limit in total. + let total = 128 * TO_PROVIDER_LIMIT as u64; + let mut pending = Pending::default(); + let mut peer = SlowPeer { + received: 0, + calls: 0, + }; + let mut sent = 0_u64; + let mut largest = 0; + let mut chunk = vec![0_u8; CHUNK]; + while peer.received < total { + if pending.len() < TO_PROVIDER_LIMIT && sent < total { + let count = CHUNK.min((total - sent) as usize); + for (offset, byte) in chunk[..count].iter_mut().enumerate() { + *byte = ((sent + offset as u64) % 251) as u8; + } + pending.push(&chunk[..count]); + sent += count as u64; + } + pending.flush(&mut peer).unwrap(); + largest = largest.max(pending.bytes.capacity()); + } + assert!(pending.is_empty()); + assert_eq!(peer.received, total); + // Delivered bytes are dropped: storage stays within a small multiple of the limit. + assert!( + largest <= 4 * (TO_PROVIDER_LIMIT + CHUNK), + "buffer grew to {largest} bytes for a {TO_PROVIDER_LIMIT}-byte limit" + ); +} diff --git a/packages/runtime-core/src/provider/image_build/output.rs b/packages/runtime-core/src/provider/image_build/output.rs new file mode 100644 index 000000000..ce5d23e1c --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/output.rs @@ -0,0 +1,486 @@ +//! The build client's output boundary. +//! +//! The client's stderr is a pipe read here, never inherited. Each line is redacted of +//! every build-argument value (defense in depth; arguments are not secrets), stripped of +//! terminal control characters and escape sequences, cut at a fixed length, and counted +//! against a fixed budget before it is written to the caller's stream. Each value is +//! matched raw and in the forms the client prints it in: Go `%q` quoting, JSON string +//! escaping (with and without Go's HTML escaping), and the bake definition's own +//! encoding, which the client quotes in its parse errors. Redaction is otherwise literal: +//! a Dockerfile that transforms a value before printing it (encoding, slicing) is not +//! detected. +use std::{ + io::{Read, Write}, + sync::{ + Arc, Mutex, TryLockError, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +pub(super) const REDACTED: &str = "[redacted]"; +const LINE_CUT: &str = " [line truncated]"; +const OUTPUT_CUT: &str = "[build output truncated]\n"; +/// Longest forwarded line; the rest of a longer line is dropped. +const MAX_LINE_BYTES: usize = 16 * 1024; +/// Total forwarded bytes; later output is drained and dropped. +const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +/// How long forwarding may continue after the client exits (a process outside its group +/// can hold the pipe open). After that, nothing more is written. +const DRAIN_WAIT: Duration = Duration::from_secs(2); +/// How long closing waits for a write already in progress. A caller stream that stops +/// draining must not hold the operation (and its build fence) open. +const CLOSE_WAIT: Duration = Duration::from_secs(1); + +/// Line filter: redaction, terminal sanitizing and bounds. +pub(super) struct Filter { + /// Literal byte strings to redact, longest first. + needles: Vec>, + forwarded: usize, + exhausted: bool, +} + +/// Keep tabs and printable characters; drop every other control character, which +/// removes the ESC that starts any terminal escape sequence. +fn sanitize(bytes: &[u8]) -> Vec { + String::from_utf8_lossy(bytes) + .chars() + .filter(|c| *c == '\t' || !c.is_control()) + .collect::() + .into_bytes() +} + +/// The body of Go's `strconv.Quote` (without the surrounding quotes). Printability +/// follows Rust's control and whitespace classes, a close approximation of Go's +/// `unicode.IsPrint`. +fn go_quoted(value: &str) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{7}' => out.push_str("\\a"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{b}' => out.push_str("\\v"), + c if (c as u32) < 0x80 && c.is_control() => { + out.push_str(&format!("\\x{:02x}", c as u32)); + } + c if c.is_control() || (c.is_whitespace() && c != ' ') => { + if (c as u32) < 0x10000 { + out.push_str(&format!("\\u{:04x}", c as u32)); + } else { + out.push_str(&format!("\\U{:08x}", c as u32)); + } + } + c => out.push(c), + } + } + out +} + +/// The body of a JSON string. `html` adds Go `encoding/json`'s default escaping of +/// `<`, `>`, `&`, U+2028 and U+2029. +fn json_escaped(value: &str, html: bool) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + '<' | '>' | '&' | '\u{2028}' | '\u{2029}' if html => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out +} + +/// Every form of one value the filter redacts. +fn value_forms(value: &str) -> Vec { + let mut forms = vec![ + value.to_owned(), + go_quoted(value), + json_escaped(value, false), + json_escaped(value, true), + json_escaped(&super::bake_literal(value), false), + ]; + // A raw value spanning lines is also caught line by line. + forms.extend(value.split(['\n', '\r']).map(str::to_owned)); + forms +} + +/// The length of the longest suffix of `bytes` that is a proper prefix of a needle. +fn partial_tail(bytes: &[u8], needles: &[Vec]) -> usize { + needles + .iter() + .filter_map(|needle| { + (1..needle.len().min(bytes.len() + 1)) + .rev() + .find(|k| bytes.ends_with(&needle[..*k])) + }) + .max() + .unwrap_or(0) +} + +fn redact(bytes: &[u8], needles: &[Vec]) -> Vec { + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + 'scan: while at < bytes.len() { + for needle in needles { + if bytes[at..].starts_with(needle) { + out.extend_from_slice(REDACTED.as_bytes()); + at += needle.len(); + continue 'scan; + } + } + out.push(bytes[at]); + at += 1; + } + out +} + +impl Filter { + /// Redact every form of each value (see [`value_forms`]), raw and as sanitizing + /// would leave it, so a value containing quotes, escapes, control characters or line + /// breaks is still caught. + pub(super) fn new<'a>(values: impl IntoIterator) -> Self { + let mut needles = Vec::new(); + for value in values { + for form in value_forms(value) { + needles.push(sanitize(form.as_bytes())); + needles.push(form.into_bytes()); + } + } + needles.retain(|needle| !needle.is_empty()); + needles.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + needles.dedup(); + Self { + needles, + forwarded: 0, + exhausted: false, + } + } + + /// The bytes to forward for one line (without its terminator). Values are redacted + /// in the raw bytes (catching values that contain control characters), the result + /// is sanitized, and redacted again in the sanitized bytes that are forwarded. `cut` + /// marks a line longer than the line bound whose remainder is dropped: a trailing + /// partial match of any value is then trimmed from the sanitized bytes, the ones + /// actually forwarded, so no prefix of a value survives the cut. + pub(super) fn line(&mut self, line: &[u8], cut: bool) -> Vec { + if self.exhausted { + return Vec::new(); + } + let mut clean = sanitize(&redact(line, &self.needles)); + let mut partial = false; + if cut { + let tail = partial_tail(&clean, &self.needles); + clean.truncate(clean.len() - tail); + partial = tail > 0; + } + let mut out = redact(&clean, &self.needles); + if partial { + out.extend_from_slice(REDACTED.as_bytes()); + } + if cut { + out.extend_from_slice(LINE_CUT.as_bytes()); + } + out.push(b'\n'); + if self.forwarded + out.len() > MAX_OUTPUT_BYTES { + self.exhausted = true; + return OUTPUT_CUT.as_bytes().to_vec(); + } + self.forwarded += out.len(); + out + } + + /// Split a byte stream into bounded lines and filter each one. + fn pump(&mut self, mut source: impl Read, mut emit: impl FnMut(&[u8]) -> bool) { + let mut line = Vec::new(); + let mut skipping = false; + let mut chunk = [0_u8; 8192]; + loop { + let size = match source.read(&mut chunk) { + Ok(0) => break, + Ok(size) => size, + // The cancellation handlers are installed without SA_RESTART. + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + for &byte in &chunk[..size] { + if byte == b'\n' { + if !skipping && !emit(&self.line(&line, false)) { + return; + } + line.clear(); + skipping = false; + } else if !skipping { + line.push(byte); + if line.len() == MAX_LINE_BYTES { + if !emit(&self.line(&line, true)) { + return; + } + line.clear(); + skipping = true; + } + } + } + } + if !line.is_empty() && !skipping { + emit(&self.line(&line, false)); + } + } +} + +/// Forwards one pipe through a [`Filter`] on its own thread. Dropping it waits a +/// bounded time for the pipe to close, then marks the sink closed. Each write checks +/// that mark while holding the sink lock, and dropping waits (bounded) for that lock, +/// so no write starts after the drop returns. A write already blocked on a caller +/// stream that stopped draining is abandoned after [`CLOSE_WAIT`] rather than awaited. +pub(super) struct Forwarder { + sink: Arc>>, + closed: Arc, + done: mpsc::Receiver<()>, + thread: Option>, +} + +impl Forwarder { + pub(super) fn spawn( + source: impl Read + Send + 'static, + mut filter: Filter, + sink: Box, + ) -> Self { + let sink = Arc::new(Mutex::new(sink)); + let closed = Arc::new(AtomicBool::new(false)); + let (shared, stop) = (Arc::clone(&sink), Arc::clone(&closed)); + let (sender, done) = mpsc::channel(); + let thread = std::thread::spawn(move || { + filter.pump(source, |bytes| { + let mut sink = shared.lock().unwrap_or_else(|e| e.into_inner()); + if stop.load(Ordering::Acquire) { + return false; + } + // A failing caller stream must not stall the client; keep draining. + let _ = sink.write_all(bytes).and_then(|()| sink.flush()); + true + }); + let _ = sender.send(()); + }); + Self { + sink, + closed, + done, + thread: Some(thread), + } + } +} + +impl Drop for Forwarder { + fn drop(&mut self) { + let finished = self.done.recv_timeout(DRAIN_WAIT).is_ok(); + self.closed.store(true, Ordering::Release); + // Never block on the lock: a writer stuck on a full caller pipe holds it. + let deadline = Instant::now() + CLOSE_WAIT; + while matches!(self.sink.try_lock(), Err(TryLockError::WouldBlock)) + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(5)); + } + if let Some(thread) = self.thread.take().filter(|_| finished) { + let _ = thread.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filtered(values: &[&str], input: &[u8]) -> String { + let mut filter = Filter::new(values.iter().copied()); + let mut out = Vec::new(); + filter.pump(input, |bytes| { + out.extend_from_slice(bytes); + true + }); + String::from_utf8(out).unwrap() + } + + #[test] + fn values_are_redacted_even_across_lines_and_control_characters() { + let out = filtered( + &["s3cret", "two\nlines", "es\x1bc"], + b"#5 s3cret and s3cret\nhead two\nlines tail\nraw es\x1bc and es[c\n", + ); + assert!(!out.contains("s3cret") && !out.contains("two") && !out.contains("lines")); + assert_eq!(out.matches(REDACTED).count(), 5, "{out}"); + assert!(!out.contains("esc"), "{out}"); + assert!(!out.contains('\x1b')); + } + + #[test] + fn quoted_and_escaped_forms_of_values_are_redacted() { + let value = "p\"a\\s&\nw\u{1b}d"; + let forms = [ + // Go %q, as buildx prints values in errors. + "p\\\"a\\\\s&\\nw\\x1bd", + // JSON, and Go's HTML-escaped JSON. + "p\\\"a\\\\s&\\nw\\u001bd", + "p\\\"a\\\\s\\u003cs\\u003e\\u0026\\nw\\u001bd", + ]; + for form in forms { + let out = filtered(&[value], format!("err: \"{form}\" done\n").as_bytes()); + assert_eq!(out, format!("err: \"{REDACTED}\" done\n"), "{form}"); + } + // The bake definition encodes `${`, and the client quotes it in parse errors. + let out = filtered(&["a${b}c"], b"1 | {\"args\":{\"X\":\"a$${b}c\"}}\n"); + assert_eq!(out, format!("1 | {{\"args\":{{\"X\":\"{REDACTED}\"}}}}\n")); + assert_eq!( + go_quoted("\u{2028}\u{1f600}\u{7f}"), + "\\u2028\u{1f600}\\x7f" + ); + } + + #[test] + fn a_value_split_by_a_cut_after_sanitizing_leaves_no_prefix() { + // Raw bytes end in ESC, so only the sanitized (forwarded) bytes end in a prefix. + let mut input = vec![b'x'; MAX_LINE_BYTES - 4]; + input.extend_from_slice(b"abc\x1bdefgh tail\n"); + let out = filtered(&["abcdefgh"], &input); + let first = out.lines().next().unwrap(); + assert!( + first.ends_with(&format!("x{REDACTED}{LINE_CUT}")), + "{:?}", + &first[first.len() - 40..] + ); + assert!(!out.contains("abc")); + } + + #[test] + fn terminal_control_sequences_are_stripped_and_tabs_kept() { + let out = filtered( + &[], + b"\x1b[2J\x1b]0;title\x07ok\tdone\r\n\x00\x7f\xc2\x9bend", + ); + assert_eq!(out, "[2J]0;titleok\tdone\nend\n"); + assert!( + out.bytes() + .all(|b| b == b'\t' || b == b'\n' || !b.is_ascii_control()) + ); + } + + #[test] + fn long_lines_are_cut_without_leaving_a_value_prefix() { + let secret = "abcdefgh"; + let mut input = vec![b'x'; MAX_LINE_BYTES - 3]; + input.extend_from_slice(b"abcdefgh-rest-of-line\nnext\n"); + let out = filtered(&[secret], &input); + let first = out.lines().next().unwrap(); + assert!( + first.ends_with(&format!("{REDACTED}{LINE_CUT}")), + "{first:?}" + ); + assert!(!first.contains("abc")); + assert!(!out.contains("rest-of-line")); + assert!(out.ends_with("next\n")); + } + + #[test] + fn output_beyond_the_budget_is_dropped_with_one_notice() { + let line = vec![b'y'; 1023]; + let mut input = Vec::new(); + for _ in 0..(MAX_OUTPUT_BYTES / 1024 + 10) { + input.extend_from_slice(&line); + input.push(b'\n'); + } + let out = filtered(&[], &input); + assert!(out.len() <= MAX_OUTPUT_BYTES + OUTPUT_CUT.len()); + assert!(out.ends_with(OUTPUT_CUT)); + assert_eq!(out.matches(OUTPUT_CUT).count(), 1); + } + + #[test] + fn a_caller_stream_that_stops_draining_cannot_hold_shutdown() { + let (release, blocked) = mpsc::channel::<()>(); + let writes = Arc::new(Mutex::new(0_usize)); + struct Stuck(mpsc::Receiver<()>, Arc>); + impl Write for Stuck { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + *self.1.lock().unwrap() += 1; + // Like a full pipe whose reader stopped consuming. + let _ = self.0.recv(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let (reader, writer) = std::os::unix::net::UnixStream::pair().unwrap(); + let forwarder = Forwarder::spawn( + reader, + Filter::new([]), + Box::new(Stuck(blocked, Arc::clone(&writes))), + ); + (&writer).write_all(b"one\n").unwrap(); + let started = Instant::now(); + while *writes.lock().unwrap() == 0 { + assert!(started.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(5)); + } + (&writer).write_all(b"two\n").unwrap(); + let started = Instant::now(); + drop(forwarder); + let elapsed = started.elapsed(); + assert!( + elapsed < DRAIN_WAIT + CLOSE_WAIT + Duration::from_secs(2), + "shutdown waited {elapsed:?} on a blocked writer" + ); + // The blocked write completes later; no further line is written after close. + release.send(()).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*writes.lock().unwrap(), 1); + } + + #[test] + fn a_held_pipe_does_not_outlive_the_forwarder() { + let (reader, writer) = std::os::unix::net::UnixStream::pair().unwrap(); + let captured = Arc::new(Mutex::new(Vec::new())); + struct Shared(Arc>>); + impl Write for Shared { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let forwarder = Forwarder::spawn( + reader, + Filter::new(["v"]), + Box::new(Shared(Arc::clone(&captured))), + ); + (&writer).write_all(b"before v\n").unwrap(); + let started = std::time::Instant::now(); + while captured.lock().unwrap().is_empty() { + assert!(started.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(5)); + } + drop(forwarder); + (&writer).write_all(b"after\n").unwrap(); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(&*captured.lock().unwrap(), b"before [redacted]\n"); + } +} diff --git a/packages/runtime-core/src/provider/image_build/reclaim.rs b/packages/runtime-core/src/provider/image_build/reclaim.rs new file mode 100644 index 000000000..4e19e718d --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/reclaim.rs @@ -0,0 +1,433 @@ +//! `runtime reclaim-build-images`: remove superseded Hack-built images and trim cache. +//! +//! An image is removed only when every one of these holds; anything else is retained +//! and reported: +//! +//! - it carries exactly this pool's build ownership labels (kind, owner digest of the +//! current incarnation, a namespace and a build key) and no other reserved labels; +//! - no build receipt of any namespace names it as its verified image; +//! - no retained graph state (`run/graphs`, `run/native-graphs`, `run/graph-archive`) +//! mentions its content ID anywhere, whatever the graph's phase or branch; +//! - the engine agrees to remove it without force, which it refuses while any +//! container, running or stopped, uses the image. Untagged parent images are never +//! pruned along with it (`noprune=true`); each must pass these checks itself. +//! +//! The operation runs under the provider lease and refuses while a build is admitted, +//! so no build can adopt an image between the reference scan and the removal. If the +//! reference scan cannot read every retained graph record safely, nothing is removed. +//! BuildKit cache is then pruned to the build cache budget; that never removes images. +use super::{ + BuildPool, CACHE_BUDGET, LivePool, Phase, RESERVED_LABEL_PREFIXES, Receipt, builds_root, hex64, + image_id, owner_label, refusal, require_no_active_build, +}; +use crate::{Candidate, CandidateError}; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io::Read, + os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + path::Path, +}; + +const MAX_SCAN_ENTRIES: usize = 20_000; +const MAX_SCAN_DEPTH: usize = 8; +const MAX_SCAN_FILE: u64 = 16 * 1024 * 1024; +const MAX_BUILD_IMAGES: usize = 4096; + +/// Why an owned-looking image was kept. +#[derive(Debug, Serialize, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "kebab-case")] +pub enum Retained { + /// Its labels are not exactly this pool's build ownership labels. + NotOwned, + /// A build receipt names it as a key's current image. + Current, + /// Retained graph state references it. + Referenced, + /// The engine refused removal (in use by a container) or the outcome is unknown. + EngineRefused, +} + +#[derive(Debug, Serialize)] +pub struct ReclaimReport { + pub removed: Vec, + pub retained: BTreeMap, + /// Build cache bytes reclaimed; `None` when the prune failed (images unaffected). + pub cache_reclaimed_bytes: Option, +} + +/// Engine effects reclamation needs beyond a build pool. +pub(super) trait ReclaimPool: BuildPool { + /// Images labeled `io.hack-local.kind=build-image`, as the engine lists them. + fn build_images(&self, lease: &Self::Lease) -> Result, CandidateError>; + /// Remove one image by content ID without force. `Ok(false)` when the engine refuses. + fn remove(&self, lease: &Self::Lease, image: &str) -> Result; +} + +fn unprovable() -> CandidateError { + refusal( + "native_build_reclaim_unprovable", + "Retained graph state could not be read completely; no build image was removed.", + ) +} + +/// Every `sha256:<64 hex>` token in the bytes. +fn image_tokens(bytes: &[u8], into: &mut BTreeSet) { + const PREFIX: &[u8] = b"sha256:"; + let mut index = 0; + while let Some(offset) = bytes[index..] + .windows(PREFIX.len()) + .position(|window| window == PREFIX) + { + let start = index + offset + PREFIX.len(); + if let Some(hex) = bytes.get(start..start + 64) + && let Ok(hex) = std::str::from_utf8(hex) + && hex64(hex) + && !bytes.get(start + 64).is_some_and(|b| b.is_ascii_hexdigit()) + { + into.insert(format!("sha256:{hex}")); + } + index = start; + } +} + +/// Collect every image ID retained graph state mentions. Symlinks, special files, +/// unreadable entries or exceeded bounds refuse: reclamation needs a complete scan. +pub(super) fn referenced_images(candidate: &Candidate) -> Result, CandidateError> { + let mut found = BTreeSet::new(); + let mut entries = 0; + for name in ["run/graphs", "run/native-graphs", "run/graph-archive"] { + let root = candidate.state_root.join(name); + match fs::symlink_metadata(&root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(unprovable()), + Ok(metadata) if !metadata.is_dir() => return Err(unprovable()), + Ok(_) => {} + } + scan_directory(&root, 0, &mut entries, &mut found)?; + } + Ok(found) +} + +fn scan_directory( + directory: &Path, + depth: usize, + entries: &mut usize, + found: &mut BTreeSet, +) -> Result<(), CandidateError> { + if depth > MAX_SCAN_DEPTH { + return Err(unprovable()); + } + for entry in fs::read_dir(directory).map_err(|_| unprovable())? { + *entries += 1; + if *entries > MAX_SCAN_ENTRIES { + return Err(unprovable()); + } + let path = entry.map_err(|_| unprovable())?.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| unprovable())?; + if metadata.is_dir() { + scan_directory(&path, depth + 1, entries, found)?; + } else if metadata.is_file() { + if metadata.len() > MAX_SCAN_FILE { + return Err(unprovable()); + } + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + .map_err(|_| unprovable())?; + let opened = file.metadata().map_err(|_| unprovable())?; + if !opened.is_file() || (opened.dev(), opened.ino()) != (metadata.dev(), metadata.ino()) + { + return Err(unprovable()); + } + let mut bytes = Vec::new(); + file.take(MAX_SCAN_FILE + 1) + .read_to_end(&mut bytes) + .map_err(|_| unprovable())?; + if bytes.len() as u64 > MAX_SCAN_FILE { + return Err(unprovable()); + } + image_tokens(&bytes, found); + } else if !metadata.file_type().is_socket() { + // Sockets carry no records; anything else is unexpected in graph state. + return Err(unprovable()); + } + } + Ok(()) +} + +/// Image IDs that build receipts of every namespace record as verified. +fn current_images(candidate: &Candidate) -> Result, CandidateError> { + let root = builds_root(candidate); + let mut current = BTreeSet::new(); + let namespaces = match fs::read_dir(&root) { + Ok(namespaces) => namespaces, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current), + Err(_) => return Err(unprovable()), + }; + for namespace in namespaces { + let namespace = namespace.map_err(|_| unprovable())?.path(); + let name = namespace.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let metadata = fs::symlink_metadata(&namespace).map_err(|_| unprovable())?; + if name == "active.lock" && metadata.is_file() { + continue; + } + if !hex64(name) || !metadata.is_dir() { + return Err(unprovable()); + } + for entry in fs::read_dir(&namespace).map_err(|_| unprovable())? { + let path = entry.map_err(|_| unprovable())?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let receipt: Receipt = + crate::provider::state::read_bounded(&path, super::MAX_RECEIPT_BYTES) + .map_err(|_| unprovable())?; + if receipt.phase == Phase::ContentIdVerified + && let Some(id) = receipt.image_id + { + current.insert(id); + } + } + } + Ok(current) +} + +/// Whether listed labels are exactly this pool's build ownership labels. +fn owned_by(labels: &Value, owner: &str) -> bool { + let Some(labels) = labels.as_object() else { + return false; + }; + let text = |key: &str| labels.get(key).and_then(Value::as_str); + let reserved_extra = labels.keys().any(|key| { + RESERVED_LABEL_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + && ![ + "io.hack-local.build-key", + "io.hack-local.kind", + "io.hack-local.namespace", + "io.hack-local.owner", + ] + .contains(&key.as_str()) + }); + text("io.hack-local.kind") == Some(super::KIND) + && text("io.hack-local.owner") == Some(owner) + && text("io.hack-local.namespace").is_some_and(hex64) + && text("io.hack-local.build-key").is_some_and(hex64) + && !reserved_extra +} + +/// The removal decision for each listed image, before any engine effect. +pub(super) fn plan( + images: &[Value], + owner: &str, + current: &BTreeSet, + referenced: &BTreeSet, +) -> Result<(Vec, BTreeMap), CandidateError> { + if images.len() > MAX_BUILD_IMAGES { + return Err(refusal( + "native_build_reclaim_bounds", + "Too many build images to reclaim in one operation; nothing was removed.", + )); + } + let mut remove = Vec::new(); + let mut retained = BTreeMap::new(); + for image in images { + let id = image["Id"] + .as_str() + .filter(|id| image_id(id)) + .ok_or_else(|| { + refusal( + "engine_protocol", + "The engine listed a build image without a content ID.", + ) + })? + .to_owned(); + let reason = if !owned_by(&image["Labels"], owner) { + Some(Retained::NotOwned) + } else if current.contains(&id) { + Some(Retained::Current) + } else if referenced.contains(&id) { + Some(Retained::Referenced) + } else { + None + }; + match reason { + Some(reason) => { + retained.insert(id, reason); + } + None => remove.push(id), + } + } + remove.sort(); + remove.dedup(); + Ok((remove, retained)) +} + +pub(super) fn run( + candidate: &Candidate, + pool: &P, +) -> Result { + let (lease, identity) = pool.lease()?; + require_no_active_build(candidate)?; + // Under the lease no build can start, adopt or write a receipt. + let current = current_images(candidate)?; + let referenced = referenced_images(candidate)?; + let images = pool.build_images(&lease)?; + let (candidates, mut retained) = plan( + &images, + &owner_label(&identity.incarnation), + ¤t, + &referenced, + )?; + let mut removed = Vec::new(); + for id in candidates { + match pool.remove(&lease, &id) { + Ok(true) => removed.push(id), + Ok(false) | Err(_) => { + retained.insert(id, Retained::EngineRefused); + } + } + } + let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok(); + Ok(ReclaimReport { + removed, + retained, + cache_reclaimed_bytes, + }) +} + +impl ReclaimPool for LivePool<'_> { + fn build_images(&self, lease: &Self::Lease) -> Result, CandidateError> { + // `{"label":["io.hack-local.kind=build-image"]}`, percent-encoded. + let value = lease.request( + reqwest::Method::GET, + "/v1.53/images/json?all=true&filters=%7B%22label%22%3A%5B%22io.hack-local.kind%3Dbuild-image%22%5D%7D", + None, + )?; + value + .as_array() + .cloned() + .ok_or_else(|| refusal("engine_protocol", "The engine image list is not an array.")) + } + fn remove(&self, lease: &Self::Lease, image: &str) -> Result { + match lease.request(reqwest::Method::DELETE, &removal_path(image), None) { + Ok(_) => Ok(true), + Err(error) if error.code == "engine_not_found" => Ok(true), + Err(error) if error.code == "engine_rejected" => Ok(false), + Err(error) => Err(error), + } + } +} + +/// Remove exactly one image: never forced, and never its untagged parents +/// (`noprune=true`), so every image the engine deletes has passed [`plan`] itself. +fn removal_path(image: &str) -> String { + format!("/v1.53/images/{image}?force=false&noprune=true") +} + +/// `runtime reclaim-build-images`: see the module documentation. +pub fn reclaim_build_images(candidate: &Candidate) -> Result { + run(candidate, &LivePool { candidate }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const D: &str = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + fn image(id: &str, owner: &str) -> Value { + serde_json::json!({"Id": id, "Labels": { + "io.hack-local.kind": "build-image", + "io.hack-local.owner": owner, + "io.hack-local.namespace": "1".repeat(64), + "io.hack-local.build-key": "2".repeat(64), + }}) + } + + #[test] + fn only_unreferenced_superseded_images_of_this_pool_are_removal_candidates() { + let owner = owner_label("pool"); + let mut forged = image(D, &owner); + forged["Labels"]["io.hack-local.graph"] = "x".into(); + let images = [ + image(A, &owner), + image(B, &owner), + image(C, &owner), + forged, + image( + "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + &owner_label("other-pool"), + ), + ]; + let current = BTreeSet::from([B.to_owned()]); + let referenced = BTreeSet::from([C.to_owned()]); + let (remove, retained) = plan(&images, &owner, ¤t, &referenced).unwrap(); + assert_eq!(remove, vec![A.to_owned()]); + assert_eq!(retained[B], Retained::Current); + assert_eq!(retained[C], Retained::Referenced); + assert_eq!(retained[D], Retained::NotOwned); + assert_eq!(retained.len(), 4); + assert!( + plan( + &[serde_json::json!({"Id": "bad"})], + &owner, + ¤t, + &referenced + ) + .is_err() + ); + } + + #[test] + fn removal_never_forces_or_prunes_untagged_parents() { + let path = removal_path(A); + assert_eq!(path, format!("/v1.53/images/{A}?force=false&noprune=true")); + assert!(!path.contains("noprune=false") && !path.contains("force=true")); + } + + #[test] + fn image_tokens_find_exact_content_ids_only() { + let mut found = BTreeSet::new(); + image_tokens( + format!("{{\"image\":\"{A}\"}} x{B}f sha256:short {C}").as_bytes(), + &mut found, + ); + // B is followed by another hex digit, so it is not an exact ID. + assert_eq!(found, BTreeSet::from([A.to_owned(), C.to_owned()])); + } + + #[test] + fn retained_graph_state_is_scanned_completely_or_not_at_all() { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-build-reclaim"); + let checkout = root.join("checkout"); + crate::provider::state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + assert!(referenced_images(&candidate).unwrap().is_empty()); + let run = candidate.state_root.join("run/native-graphs/r1/nested"); + crate::provider::state::private_directory(&run).unwrap(); + fs::write(run.join("receipt.json"), format!("{{\"image\":\"{A}\"}}")).unwrap(); + let archive = candidate.state_root.join("run/graph-archive"); + crate::provider::state::private_directory(&archive).unwrap(); + fs::write(archive.join("old.json"), format!("[\"{B}\"]")).unwrap(); + assert_eq!( + referenced_images(&candidate).unwrap(), + BTreeSet::from([A.to_owned(), B.to_owned()]) + ); + std::os::unix::fs::symlink(run.join("receipt.json"), archive.join("alias")).unwrap(); + assert_eq!( + referenced_images(&candidate).unwrap_err().code, + "native_build_reclaim_unprovable" + ); + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs new file mode 100644 index 000000000..921b3d97c --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -0,0 +1,1386 @@ +//! `runtime build-image` against a fake pool and a fake build client. +//! +//! The fake client is `/bin/sh` reading a script named `bake` (its first argument) from +//! the client's private working directory, so no test writes and then executes a fresh +//! binary. It records its argv, its whole environment, a copy of the bake definition, +//! the staged Dockerfile and ignore rules, and a call count. +use super::*; +use std::{ + cell::RefCell, + os::unix::fs::PermissionsExt, + sync::{Arc, Mutex}, +}; + +const IMAGE: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PIN: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const SECRET: &str = "literal-value-that-must-not-leak"; + +struct Fixture { + root: PathBuf, + candidate: Candidate, + project: PathBuf, +} +impl Fixture { + fn new() -> Self { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-image-build"); + let checkout = root.join("checkout"); + state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + let project = root.join("project"); + fs::create_dir(&project).unwrap(); + fs::create_dir(project.join("app")).unwrap(); + fs::write( + project.join("app/Dockerfile"), + "ARG BASE=alpine:3.20\nFROM ${BASE}\nARG GREETING\nRUN echo hi\n", + ) + .unwrap(); + fs::write(project.join("app/main.txt"), "one").unwrap(); + Self { + root, + candidate, + project, + } + } + /// A fixture another process created at `root` (see `coordinator_child`). + fn at(root: PathBuf) -> Self { + Self { + candidate: Candidate::discover(&root.join("checkout")).unwrap(), + project: root.join("project"), + root, + } + } + fn request(&self, policy: Policy) -> BuildRequest { + BuildRequest { + version: 1, + project_root: self.project.clone(), + branch: None, + context: "app".into(), + dockerfile: "Dockerfile".into(), + target: None, + args: BTreeMap::from([("GREETING".to_owned(), SECRET.to_owned())]), + policy, + timeout_seconds: Some(30), + } + } + fn inputs(&self, policy: Policy) -> Inputs { + prepare(self.request(policy)).unwrap() + } + fn scratch(&self, inputs: &Inputs) -> PathBuf { + builds_root(&self.candidate) + .join(&inputs.namespace) + .join(format!("{}.client", inputs.key)) + } + fn receipt(&self, inputs: &Inputs) -> Receipt { + state::read( + &builds_root(&self.candidate) + .join(&inputs.namespace) + .join(format!("{}.json", inputs.key)), + ) + .unwrap() + } + /// Install the fake client for this build key. `mode` selects its behavior. + fn client(&self, inputs: &Inputs, mode: &str) { + let scratch = self.scratch(inputs); + state::private_directory(&scratch).unwrap(); + let script = format!( + r#"set -eu +count=$(cat calls 2>/dev/null || echo 0) +echo $((count + 1)) > calls +printf '%s\n' "$@" > argv +env > env +[ -n "${{EXPERIMENTAL_BUILDKIT_SOURCE_POLICY-}}" ] || exit 9 +cat "$EXPERIMENTAL_BUILDKIT_SOURCE_POLICY" > seen-policy.json +printf '%s' "$EXPERIMENTAL_BUILDKIT_SOURCE_POLICY" > seen-policy-path +meta= +file= +previous= +for argument do + if [ "$previous" = --metadata-file ]; then meta=$argument; fi + if [ "$previous" = --file ]; then file=$argument; fi + previous=$argument +done +cat "$file" > seen-bake.json +cat dockerfile/Dockerfile > seen-dockerfile +cat dockerfile/Dockerfile.dockerignore > seen-dockerignore +case {mode} in + ok) printf '{{"build":{{"containerimage.config.digest":"{IMAGE}"}}}}' > "$meta" ;; + leak) + printf '#5 0.1 value=%s\n\033[31mred\033[0m\033]0;title\007\n' '{SECRET}' >&2 + printf '{{"build":{{"containerimage.config.digest":"{IMAGE}"}}}}' > "$meta" ;; + fail) exit 3 ;; + nometa) ;; + sleep) sleep 30 & echo $! > child.pending; mv child.pending child; wait ;; + hold) echo $$ > client-pid.pending; mv client-pid.pending client-pid; sleep 30 ;; +esac +"# + ); + fs::write(scratch.join("bake"), script).unwrap(); + fs::set_permissions(scratch.join("bake"), fs::Permissions::from_mode(0o600)).unwrap(); + } + fn calls(&self, inputs: &Inputs) -> u32 { + fs::read_to_string(self.scratch(inputs).join("calls")) + .map(|v| v.trim().parse().unwrap()) + .unwrap_or(0) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn identity(incarnation: &str) -> PoolIdentity { + PoolIdentity { + incarnation: incarnation.into(), + boot_id: "boot-1".into(), + engine_id: "engine-1".into(), + socket: PathBuf::from("/nonexistent/docker.sock"), + } +} + +/// A fake pool: identities returned per lease in order (the last repeats), images by ID. +struct FakePool { + removable: RefCell>, + removed: RefCell>, + identities: RefCell>, + images: RefCell>, + leases: RefCell, + resolve_error: bool, + available: RefCell, + prunes: RefCell>, + resolve_delay: Duration, + resolve_deadlines: RefCell>, + output: Arc>>, +} +impl FakePool { + fn new(incarnation: &str) -> Self { + Self { + removable: RefCell::new(BTreeMap::new()), + removed: RefCell::new(Vec::new()), + identities: RefCell::new(vec![identity(incarnation)]), + images: RefCell::new(BTreeMap::new()), + leases: RefCell::new(0), + resolve_error: false, + available: RefCell::new(64 * GIB), + prunes: RefCell::new(Vec::new()), + resolve_delay: Duration::ZERO, + resolve_deadlines: RefCell::new(Vec::new()), + output: Arc::default(), + } + } + /// Register the image the fake client "builds", with the labels the build expects. + fn built(&self, inputs: &Inputs, incarnation: &str, extra: Value) { + let mut labels = serde_json::to_value(labels(inputs, &identity(incarnation))).unwrap(); + if let Value::Object(more) = extra { + labels.as_object_mut().unwrap().extend(more); + } + self.images.borrow_mut().insert( + IMAGE.into(), + serde_json::json!({"Id": IMAGE, "Architecture": "arm64", "Os": "linux", + "Size": 5 * GIB, "Config": {"Labels": labels, "Volumes": null}}), + ); + } +} +impl BuildPool for FakePool { + type Lease = (); + fn lease(&self) -> Result<((), PoolIdentity), CandidateError> { + *self.leases.borrow_mut() += 1; + let mut identities = self.identities.borrow_mut(); + let current = if identities.len() > 1 { + identities.remove(0) + } else { + identities[0].clone() + }; + Ok(((), current)) + } + fn inspect(&self, _: &(), image: &str) -> Result, CandidateError> { + Ok(self.images.borrow().get(image).cloned()) + } + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + _: &dyn Fn() -> bool, + ) -> Result { + self.resolve_deadlines.borrow_mut().push(deadline); + std::thread::sleep(self.resolve_delay); + if self.resolve_error { + return Err(CandidateError::new("registry_image", "offline")); + } + Ok(BuildBase { + pinned_reference: format!("docker.io/{}@{PIN}", base.repository()), + source_digest: PIN.into(), + image_id: IMAGE.into(), + }) + } + fn builder(&self) -> Result { + Ok(PathBuf::from("/bin/sh")) + } + fn output(&self) -> Box { + Box::new(Captured(Arc::clone(&self.output))) + } + fn storage_available(&self, _: &()) -> Result { + Ok(*self.available.borrow()) + } + fn prune_cache(&self, _: &(), max_used: u64) -> Result { + self.prunes.borrow_mut().push(max_used); + Ok(7) + } +} + +/// The caller's stream as the fake pool sees it. +struct Captured(Arc>>); +impl Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn never() -> bool { + false +} + +#[test] +fn cold_build_passes_values_only_through_the_private_definition_and_records_the_result() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(outcome.image_id, IMAGE); + assert!(!outcome.reused); + assert_eq!( + outcome.base_pins, + BTreeMap::from([( + "alpine:3.20".to_owned(), + format!("docker.io/library/alpine@{PIN}") + )]) + ); + let scratch = fixture.scratch(&inputs); + let argv = fs::read_to_string(scratch.join("argv")).unwrap(); + assert!(!argv.contains(SECRET), "argument value reached argv"); + let lines: Vec<&str> = argv.lines().collect(); + // `sh` ran the script named by the first argument, so that argument was `bake`. + assert_eq!(lines.last(), Some(&"build")); + assert!(lines.contains(&"--provenance=false") && lines.contains(&"--sbom=false")); + assert!(lines.windows(2).any(|w| w[0] == "--allow" + && w[1] == format!("fs.read={}", fixture.project.join("app").display()))); + assert!( + !argv.contains("pool-1"), + "the pool incarnation reached argv" + ); + let env = fs::read_to_string(scratch.join("env")).unwrap(); + assert!( + !env.contains(SECRET), + "argument value reached the client environment" + ); + assert!(!env.lines().any(|line| line.starts_with("GREETING="))); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + let target = &bake["target"]["build"]; + assert_eq!(target["args"], serde_json::json!({"GREETING": SECRET})); + assert_eq!( + target["contexts"], + serde_json::json!({ + "alpine:3.20": format!("docker-image://docker.io/library/alpine@{PIN}") + }) + ); + assert_eq!(target["platforms"], serde_json::json!(["linux/arm64"])); + assert_eq!(target["output"], serde_json::json!(["type=docker"])); + assert_eq!(target["labels"]["io.hack-local.kind"], "build-image"); + assert_eq!(target["tags"], serde_json::json!([inputs.tag()])); + // The definition carries argument values; it is gone once the client has run. + assert!(!scratch.join("definition").join(BAKE_DEFINITION).exists()); + // The client ran under the source policy for exactly these pins, from a private + // file removed with the definition. + let policy_path = PathBuf::from(fs::read_to_string(scratch.join("seen-policy-path")).unwrap()); + assert_eq!(policy_path, scratch.join("definition").join(SOURCE_POLICY)); + assert!(!policy_path.exists(), "the source policy outlived the run"); + let policy: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-policy.json")).unwrap()).unwrap(); + let pinned = format!("docker.io/library/alpine@{PIN}"); + assert_eq!(policy, build_policy::document([pinned.as_str()]).unwrap()); + assert_eq!( + policy_decision(&policy, &format!("docker-image://{pinned}")), + "ALLOW" + ); + for denied in [ + "docker-image://docker.io/library/alpine:3.20", + "docker-image://docker.io/library/busybox:latest", + "oci-layout://store@sha256:x", + "https://example.test/archive.tgz", + "git://example.test/repo.git", + "docker-image+blob://docker.io/library/alpine@sha256:x", + ] { + assert_eq!(policy_decision(&policy, denied), "DENY", "{denied}"); + } + assert_eq!(policy_decision(&policy, "local://context"), "ALLOW"); + let receipt = fixture.receipt(&inputs); + assert_eq!(receipt.phase, Phase::ContentIdVerified); + assert_eq!(receipt.image_id.as_deref(), Some(IMAGE)); + let text = fs::read_to_string( + builds_root(&fixture.candidate) + .join(&inputs.namespace) + .join(format!("{}.json", inputs.key)), + ) + .unwrap(); + assert!(!text.contains(SECRET), "argument value reached the receipt"); +} + +#[test] +fn environment_names_are_plain_arguments_that_never_reach_the_client_environment() { + let fixture = Fixture::new(); + let names = [ + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "GODEBUG", + "PATH", + "HTTPS_PROXY", + "KUBECONFIG", + "DOCKER_HOST", + ]; + let mut request = fixture.request(Policy::Missing); + for name in names { + request + .args + .insert(name.to_owned(), format!("{SECRET}-{name}")); + } + let inputs = prepare(request).unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let scratch = fixture.scratch(&inputs); + let env = fs::read_to_string(scratch.join("env")).unwrap(); + assert!( + !env.contains(SECRET), + "an argument value reached the client environment" + ); + // The client environment is exactly the fixed allowlist (plus what `sh` adds itself). + let allowed = [ + "PATH", + "LANG", + "HOME", + "TMPDIR", + "DOCKER_HOST", + "DOCKER_CONFIG", + "BUILDX_CONFIG", + "BUILDX_NO_DEFAULT_ATTESTATIONS", + "DOCKER_CLI_HINTS", + "EXPERIMENTAL_BUILDKIT_SOURCE_POLICY", + "PWD", + "OLDPWD", + "SHLVL", + "_", + ]; + for line in env.lines() { + let name = line.split('=').next().unwrap(); + assert!(allowed.contains(&name), "unexpected client variable {name}"); + } + assert!( + env.lines() + .any(|line| line == "PATH=/usr/bin:/bin:/usr/sbin:/sbin") + ); + assert!( + !env.lines() + .any(|line| line.starts_with("DYLD_") || line.starts_with("GODEBUG=")) + ); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + for name in names { + assert_eq!( + bake["target"]["build"]["args"][name], + format!("{SECRET}-{name}") + ); + } +} + +/// BuildKit's evaluation of a source identifier: the last matching rule wins and an +/// unmatched source is allowed. `WILDCARD` patterns here use only a trailing `*`. +fn policy_decision(policy: &Value, identifier: &str) -> &'static str { + let mut decision = "ALLOW"; + for rule in policy["rules"].as_array().unwrap() { + let pattern = rule["selector"]["identifier"].as_str().unwrap(); + let matched = match rule["selector"]["match_type"].as_str().unwrap() { + "EXACT" => identifier == pattern, + "WILDCARD" => identifier.starts_with(pattern.strip_suffix('*').unwrap()), + other => panic!("unexpected match type {other}"), + }; + if matched { + decision = if rule["action"] == "DENY" { + "DENY" + } else { + "ALLOW" + }; + } + } + decision +} + +#[test] +fn private_build_files_are_removed_even_when_the_client_fails() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "fail"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let scratch = fixture.scratch(&inputs); + assert!( + scratch.join("seen-policy.json").exists(), + "the client did not run" + ); + assert!(!scratch.join("definition").join(BAKE_DEFINITION).exists()); + assert!(!scratch.join("definition").join(SOURCE_POLICY).exists()); +} + +#[test] +fn receipts_with_the_most_and_longest_accepted_pins_stay_readable() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let mut receipt = Receipt { + version: 1, + checkout: fixture.candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: "i".repeat(128), + provider_boot_id: "b".repeat(128), + engine_id: "e".repeat(128), + phase: Phase::ContentIdVerified, + image_id: Some(IMAGE.into()), + image_bytes: Some(5 * GIB), + base_pins: BTreeMap::new(), + }; + // The scanner's bounds: 32 images, Docker Hub paths up to 200 bytes, tags up to + // 128, each with a digest; the pinned reference keeps the name and adds the pin. + for index in 0..build_scan::MAX_IMAGE_REFERENCES { + let repository = format!("{index:03}{}/{}", "r".repeat(96), "p".repeat(100)); + let name = format!("{repository}:{}@{PIN}", "t".repeat(128)); + receipt + .base_pins + .insert(name.clone(), format!("docker.io/{name}")); + } + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let path = directory.join(format!("{}.json", inputs.key)); + state::write(&path, &receipt).unwrap(); + assert!(fs::metadata(&path).unwrap().len() > 16 * 1024); + let read = read_receipt(&path).unwrap().unwrap(); + assert_eq!(read.base_pins.len(), build_scan::MAX_IMAGE_REFERENCES); +} + +#[test] +fn remote_add_sources_refuse_before_any_build_effect() { + let fixture = Fixture::new(); + for dockerfile in [ + "FROM alpine:3.20\nADD http://100.96.0.1:8080/secret /secret\n", + "FROM alpine:3.20\nADD https://example.test/a.tgz /a\n", + "FROM alpine:3.20\nADD git@example.test:org/repo.git /src\n", + "FROM alpine:3.20\nARG SRC\nADD $SRC /src\n", + "FROM alpine:3.20 AS base\nONBUILD ADD https://example.test/a /a\n", + ] { + fs::write(fixture.project.join("app/Dockerfile"), dockerfile).unwrap(); + let error = prepare(fixture.request(Policy::Missing)).unwrap_err(); + assert_eq!(error.code, "native_build_dockerfile", "{dockerfile}"); + assert!(!error.message.contains("100.96.0.1") && !error.message.contains("example.test")); + } + fs::write( + fixture.project.join("app/Dockerfile"), + "FROM alpine:3.20\nADD main.txt /main.txt\n", + ) + .unwrap(); + assert!(prepare(fixture.request(Policy::Missing)).is_ok()); +} + +#[test] +fn an_interrupted_receipt_publication_does_not_block_the_key() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let receipt = directory.join(format!("{}.json", inputs.key)); + // A process died after creating the temporary file and before renaming it, with + // no committed receipt yet. + fs::write(receipt.with_extension("pending"), b"{\"partial").unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(fixture.receipt(&inputs).phase, Phase::ContentIdVerified); + assert!(!receipt.with_extension("pending").exists()); + // The same with a committed receipt present: a rebuild still proceeds. + fs::write(receipt.with_extension("pending"), b"{").unwrap(); + let rebuild = fixture.inputs(Policy::Rebuild); + run(&fixture.candidate, &pool, &rebuild, &never).unwrap(); + assert_eq!(fixture.calls(&inputs), 2); +} + +/// The environment variable naming the fixture root the coordinator child builds in. +const COORDINATOR_ROOT: &str = "HACK_TEST_IMAGE_BUILD_COORDINATOR_ROOT"; + +#[test] +#[ignore = "coordinator process killed by a_killed_coordinator_leaves_its_client_fenced"] +fn coordinator_child() { + let Some(root) = std::env::var_os(COORDINATOR_ROOT) else { + return; + }; + // Never cleaned up here: the parent test owns the root, and this process is killed. + let fixture = std::mem::ManuallyDrop::new(Fixture::at(PathBuf::from(root))); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + let _ = run(&fixture.candidate, &pool, &inputs, &never); +} + +#[test] +fn a_killed_coordinator_leaves_its_client_fenced() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "hold"); + let scratch = fixture.scratch(&inputs); + let mut coordinator = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider::image_build::tests::coordinator_child", + "--ignored", + "--nocapture", + ]) + .env(COORDINATOR_ROOT, &fixture.root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + while !scratch.join("client-pid").exists() { + assert!( + started.elapsed() < Duration::from_secs(60), + "the client never started" + ); + std::thread::sleep(Duration::from_millis(20)); + } + // SIGKILL: the coordinator drops nothing, as when hack-native itself is killed. + coordinator.kill().unwrap(); + coordinator.wait().unwrap(); + let client: libc::pid_t = fs::read_to_string(scratch.join("client-pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + let root = builds_root(&fixture.candidate); + let key = root + .join(&inputs.namespace) + .join(format!("{}.lock", inputs.key)); + let fence = root.join("active.lock"); + let held = |path: &Path| { + Flock::try_lock(Flock::open(path).unwrap(), libc::LOCK_SH) + .unwrap() + .is_none() + || Flock::try_lock(Flock::open(path).unwrap(), libc::LOCK_EX) + .unwrap() + .is_none() + }; + assert!( + held(&key), + "the key lock was released under a surviving client" + ); + assert!( + held(&fence), + "the build fence was released under a surviving client" + ); + assert!(require_no_active_build(&fixture.candidate).is_err()); + let pool = FakePool::new("pool-1"); + let busy = run( + &fixture.candidate, + &pool, + &fixture.inputs(Policy::Rebuild), + &never, + ) + .unwrap_err(); + assert_eq!(busy.code, "native_build_busy"); + // SAFETY: the surviving client leads its own process group, created for it. + unsafe { libc::killpg(client, libc::SIGKILL) }; + let started = Instant::now(); + while held(&key) || held(&fence) { + assert!( + started.elapsed() < Duration::from_secs(10), + "locks were never released" + ); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(require_no_active_build(&fixture.candidate).is_ok()); +} + +#[test] +fn bake_definitions_never_evaluate_project_text() { + assert_eq!(bake_literal("${HOME}"), "$${HOME}"); + assert_eq!( + bake_literal("%{ if true }x%{ endif }"), + "%%{ if true }x%%{ endif }" + ); + assert_eq!(bake_literal("$${x} a$ % \\${y}"), "$$${x} a$ % \\$${y}"); + let escaped = bake_strings(serde_json::json!({"a${b}": ["${c}", {"d": "%{e}"}], "n": 1})); + assert_eq!( + escaped, + serde_json::json!({"a$${b}": ["$${c}", {"d": "%%{e}"}], "n": 1}) + ); + let fixture = Fixture::new(); + let mut request = fixture.request(Policy::Missing); + request + .args + .insert("TEMPLATE".into(), "${HOME}%{ if true }".into()); + let inputs = prepare(request).unwrap(); + let definition = bake_definition( + &inputs, + Path::new("/s/Dockerfile"), + &BTreeMap::new(), + &BTreeMap::new(), + ); + assert_eq!( + definition["target"]["build"]["args"]["TEMPLATE"], + "$${HOME}%%{ if true }" + ); +} + +#[test] +fn client_output_reaches_the_caller_redacted_and_without_terminal_controls() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "leak"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let output = String::from_utf8(pool.output.lock().unwrap().clone()).unwrap(); + assert_eq!(output, "#5 0.1 value=[redacted]\n[31mred[0m]0;title\n"); + assert!(!output.contains(SECRET)); +} + +#[test] +fn the_client_builds_the_scanned_dockerfile_and_ignore_rules_not_later_edits() { + let fixture = Fixture::new(); + let app = fixture.project.join("app"); + let scanned = fs::read(app.join("Dockerfile")).unwrap(); + fs::write(app.join(".dockerignore"), "*.txt\n").unwrap(); + let inputs = fixture.inputs(Policy::Missing); + // Edits after the scan (while bases resolve) must not reach the client. + fs::write(app.join("Dockerfile"), "FROM unpinned:latest\nRUN id\n").unwrap(); + fs::write(app.join(".dockerignore"), "!*\n").unwrap(); + fs::write(app.join("Dockerfile.dockerignore"), "main.txt\n").unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let scratch = fixture.scratch(&inputs); + assert_eq!(fs::read(scratch.join("seen-dockerfile")).unwrap(), scanned); + assert_eq!( + fs::read_to_string(scratch.join("seen-dockerignore")).unwrap(), + "*.txt\n" + ); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + let file = PathBuf::from(bake["target"]["build"]["dockerfile"].as_str().unwrap()); + assert!(file.starts_with(&scratch), "the client read a project path"); + + // A Dockerfile-specific ignore file wins; with none at all, the private rules are + // empty, so a context .dockerignore created later is never applied. + let inputs = fixture.inputs(Policy::Rebuild); + fs::remove_file(app.join("Dockerfile.dockerignore")).unwrap(); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!( + fs::read_to_string(fixture.scratch(&inputs).join("seen-dockerignore")).unwrap(), + "main.txt\n" + ); + fs::remove_file(app.join(".dockerignore")).unwrap(); + let inputs = fixture.inputs(Policy::Rebuild); + fs::write(app.join(".dockerignore"), "*\n").unwrap(); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!( + fs::read(fixture.scratch(&inputs).join("seen-dockerignore")).unwrap(), + b"" + ); +} + +#[test] +fn the_time_budget_starts_before_base_resolution() { + let fixture = Fixture::new(); + let mut inputs = fixture.inputs(Policy::Missing); + inputs.timeout = Duration::from_millis(300); + let mut pool = FakePool::new("pool-1"); + pool.resolve_delay = Duration::from_millis(400); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let started = Instant::now(); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + let finished = Instant::now(); + assert_eq!(error.code, "native_build_timeout"); + assert_eq!(fixture.calls(&inputs), 0, "the client ran after the budget"); + // Resolution got the operation's deadline, which had passed when it returned. + let deadlines = pool.resolve_deadlines.borrow(); + assert_eq!(deadlines.len(), 1); + assert!(deadlines[0] >= started + inputs.timeout && deadlines[0] < finished); +} + +#[test] +fn blocking_resolution_yields_to_the_deadline_and_to_cancellation() { + let slow = || { + std::thread::sleep(Duration::from_secs(3)); + Ok(()) + }; + let started = Instant::now(); + let error = interruptible(slow, started + Duration::from_millis(100), &never).unwrap_err(); + assert_eq!(error.code, "native_build_timeout"); + let error = interruptible(slow, started + Duration::from_secs(60), &|| true).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!( + interruptible(|| Ok(7), Instant::now() + Duration::from_secs(5), &never).unwrap(), + 7 + ); +} + +#[test] +fn verified_image_is_reused_without_running_the_client() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let again = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(again.reused); + assert_eq!(again.image_id, IMAGE); + assert_eq!(fixture.calls(&inputs), 1); + // An explicit rebuild runs the client again. + let rebuild = fixture.inputs(Policy::Rebuild); + let rebuilt = run(&fixture.candidate, &pool, &rebuild, &never).unwrap(); + assert!(!rebuilt.reused); + assert_eq!(fixture.calls(&inputs), 2); +} + +#[test] +fn a_new_incarnation_or_a_missing_or_relabeled_image_is_built_again() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + + // Storage recreated: the old receipt is never trusted. + let recreated = FakePool::new("pool-2"); + recreated.built(&inputs, "pool-2", serde_json::json!({})); + assert!( + !run(&fixture.candidate, &recreated, &inputs, &never) + .unwrap() + .reused + ); + assert_eq!(fixture.calls(&inputs), 2); + + // Image removed from the engine. + recreated.images.borrow_mut().clear(); + let error = run(&fixture.candidate, &recreated, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_image"); + assert_eq!(fixture.calls(&inputs), 3); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Failed); + + // Same ID with labels that are not ours is never adopted. + recreated.built(&inputs, "pool-1", serde_json::json!({})); + let error = run(&fixture.candidate, &recreated, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_reserved_label"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Failed); +} + +#[test] +fn reserved_labels_and_image_volumes_refuse_adoption() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built( + &inputs, + "pool-1", + serde_json::json!({"io.hack-local.graph": "forged"}), + ); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_reserved_label"); + assert!(!error.message.contains("forged")); + + pool.built( + &inputs, + "pool-1", + serde_json::json!({"com.docker.compose.project": "x"}), + ); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_reserved_label" + ); + + pool.built(&inputs, "pool-1", serde_json::json!({"org.example": "ok"})); + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Config"]["Volumes"] = + serde_json::json!({"/data": {}}); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_image_volumes" + ); + + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Config"]["Volumes"] = Value::Null; + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Architecture"] = "amd64".into(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_image" + ); +} + +#[test] +fn client_failure_and_missing_metadata_adopt_nothing() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + fixture.client(&inputs, "fail"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let receipt = fixture.receipt(&inputs); + assert_eq!(receipt.phase, Phase::Failed); + assert!(receipt.image_id.is_none()); + + fixture.client(&inputs, "nometa"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_image"); +} + +fn wait_dead(pid: &str) { + let deadline = Instant::now() + Duration::from_secs(30); + while process_alive(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!process_alive(pid), "a client group member survived"); +} + +fn process_alive(pid: &str) -> bool { + let pid: libc::pid_t = pid.trim().parse().unwrap(); + // SAFETY: signal 0 only probes existence. + unsafe { libc::kill(pid, 0) == 0 } +} + +#[test] +fn timeout_and_cancellation_stop_the_whole_client_group() { + let fixture = Fixture::new(); + let mut request = fixture.request(Policy::Missing); + request.timeout_seconds = Some(1); + let inputs = prepare(request).unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "sleep"); + let started = Instant::now(); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_timeout"); + assert!(started.elapsed() < Duration::from_secs(20)); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); + // On a loaded host the client may not have forked its child within the budget. + let child = fixture.scratch(&inputs).join("child"); + if let Ok(pid) = fs::read_to_string(&child) { + wait_dead(&pid); + } + + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "sleep"); + let _ = fs::remove_file(&child); + let marker = child.clone(); + let cancel = move || marker.exists(); + let error = run(&fixture.candidate, &pool, &inputs, &cancel).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); + // Cancellation was requested only once the child existed (published atomically). + wait_dead(&fs::read_to_string(&child).unwrap()); + + // A request canceled before the client starts never runs it. + let error = run(&fixture.candidate, &pool, &inputs, &|| true).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); +} + +#[test] +fn a_dead_attempt_is_recorded_indeterminate_and_built_again() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + fixture.client(&inputs, "ok"); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let path = directory.join(format!("{}.json", inputs.key)); + let leftover = Receipt { + version: 1, + checkout: fixture.candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: "pool-1".into(), + provider_boot_id: "boot-1".into(), + engine_id: "engine-1".into(), + phase: Phase::Building, + image_id: None, + image_bytes: None, + base_pins: BTreeMap::new(), + }; + state::write(&path, &leftover).unwrap(); + // Even with an adoptable image present, the dead attempt's receipt is not trusted. + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(!outcome.reused); + assert_eq!(fixture.receipt(&inputs).phase, Phase::ContentIdVerified); + + // A receipt that names another key is never adopted or replaced. + let mut foreign = leftover; + foreign.build_key = "0".repeat(64); + fs::remove_file(&path).unwrap(); + state::write(&path, &foreign).unwrap(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_receipt" + ); +} + +#[test] +fn the_pool_must_be_unchanged_between_admission_and_adoption() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let mut rebooted = identity("pool-1"); + rebooted.boot_id = "boot-2".into(); + *pool.identities.borrow_mut() = vec![identity("pool-1"), identity("pool-1"), rebooted]; + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "runtime_changed"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Indeterminate); + + // A change before the client starts runs nothing. + let pool = FakePool::new("pool-1"); + *pool.identities.borrow_mut() = vec![identity("pool-1"), identity("pool-9")]; + let calls = fixture.calls(&inputs); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "runtime_changed" + ); + assert_eq!(fixture.calls(&inputs), calls); +} + +#[test] +fn the_key_lock_and_the_down_fence_refuse_instead_of_waiting() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let held = Flock::try_lock( + Flock::open(&directory.join(format!("{}.lock", inputs.key))).unwrap(), + libc::LOCK_EX, + ) + .unwrap() + .unwrap(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_busy" + ); + drop(held); + + assert!(require_no_active_build(&fixture.candidate).is_ok()); + let build = Flock::try_lock( + Flock::open(&builds_root(&fixture.candidate).join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap(); + assert_eq!( + require_no_active_build(&fixture.candidate) + .unwrap_err() + .code, + "provider_busy" + ); + drop(build); + assert!(require_no_active_build(&fixture.candidate).is_ok()); +} + +#[test] +fn base_resolution_failures_run_nothing() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let mut pool = FakePool::new("pool-1"); + pool.resolve_error = true; + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "registry_image" + ); + assert_eq!(fixture.calls(&inputs), 0); +} + +fn code(request: BuildRequest) -> &'static str { + prepare(request).unwrap_err().code +} + +#[test] +fn requests_refuse_unsafe_paths_arguments_and_bounds() { + let fixture = Fixture::new(); + let base = || fixture.request(Policy::Missing); + + let mut request = base(); + request.version = 2; + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.project_root = PathBuf::from("relative"); + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.project_root = fixture.project.join("app/.."); + assert_eq!(code(request), "native_build_source"); + let mut request = base(); + request.context = "../outside".into(); + assert_eq!(code(request), "source_escape"); + let mut request = base(); + request.dockerfile = "../../Dockerfile".into(); + assert_eq!(code(request), "source_escape"); + std::os::unix::fs::symlink(fixture.project.join("app"), fixture.project.join("link")).unwrap(); + let mut request = base(); + request.context = "link".into(); + assert_eq!(code(request), "source_symlink"); + let mut request = base(); + request.context = "missing".into(); + assert_eq!(code(request), "missing_source"); + let mut request = base(); + request.branch = Some("Not_A_Label".into()); + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.target = Some("-bad".into()); + assert_eq!(code(request), "native_build_request"); + for timeout in [0, MAX_TIMEOUT_SECONDS + 1] { + let mut request = base(); + request.timeout_seconds = Some(timeout); + assert_eq!(code(request), "native_build_request"); + } + for name in [ + "BUILDX_CONFIG", + "BUILDKIT_SYNTAX", + "buildkit_inline_cache", + "SOURCE_DATE_EPOCH", + "TARGETPLATFORM", + "1BAD", + "bad-name", + "", + ] { + let mut request = base(); + request.args = BTreeMap::from([(name.to_owned(), "x".to_owned())]); + assert_eq!(code(request), "native_build_argument", "{name}"); + } + let mut request = base(); + request.args = BTreeMap::from([("GREETING".to_owned(), "a\0b".to_owned())]); + assert_eq!(code(request), "native_build_argument"); + let mut request = base(); + request.args = (0..=MAX_ARGS) + .map(|i| (format!("A{i}"), "x".into())) + .collect(); + assert_eq!(code(request), "native_build_argument"); + let mut request = base(); + request.args = BTreeMap::from([("BIG".to_owned(), "x".repeat(MAX_ARG_BYTES))]); + assert_eq!(code(request), "native_build_argument"); + // Refusals never echo argument values. + let mut request = base(); + request.args = BTreeMap::from([("BUILDKIT_SYNTAX".to_owned(), SECRET.to_owned())]); + assert!(!prepare(request).unwrap_err().message.contains(SECRET)); +} + +#[test] +fn the_build_key_binds_local_inputs_but_not_context_contents() { + let fixture = Fixture::new(); + let key = |request: BuildRequest| prepare(request).unwrap().key; + let original = key(fixture.request(Policy::Missing)); + assert_eq!(original, key(fixture.request(Policy::Rebuild))); + + fs::write(fixture.project.join("app/main.txt"), "two").unwrap(); + assert_eq!(original, key(fixture.request(Policy::Missing))); + + let mut request = fixture.request(Policy::Missing); + request.args.insert("GREETING".into(), "other".into()); + assert_ne!(original, key(request)); + let mut request = fixture.request(Policy::Missing); + request.branch = Some("feature".into()); + let branched = prepare(request).unwrap(); + assert_ne!(original, branched.key); + assert_ne!( + branched.namespace, + fixture.inputs(Policy::Missing).namespace + ); + + fs::write(fixture.project.join("app/.dockerignore"), "*.txt\n").unwrap(); + let ignored = key(fixture.request(Policy::Missing)); + assert_ne!(original, ignored); + fs::write( + fixture.project.join("app/Dockerfile.dockerignore"), + "*.md\n", + ) + .unwrap(); + assert_ne!(ignored, key(fixture.request(Policy::Missing))); + + fs::write( + fixture.project.join("app/Dockerfile"), + "FROM alpine:3.21\nRUN echo hi\n", + ) + .unwrap(); + assert_ne!(original, key(fixture.request(Policy::Missing))); + + let inputs = fixture.inputs(Policy::Missing); + let tag = inputs.tag(); + assert!(tag.starts_with("hack-local-build/")); + assert_eq!(tag.len(), "hack-local-build/".len() + 12 + 1 + 32); +} + +#[test] +fn dockerfile_and_ignore_inputs_must_be_plain_files() { + let fixture = Fixture::new(); + std::os::unix::fs::symlink( + fixture.project.join("app/main.txt"), + fixture.project.join("app/.dockerignore"), + ) + .unwrap(); + assert_eq!( + code(fixture.request(Policy::Missing)), + "native_build_source" + ); + fs::remove_file(fixture.project.join("app/.dockerignore")).unwrap(); + fs::hard_link( + fixture.project.join("app/Dockerfile"), + fixture.project.join("Dockerfile.copy"), + ) + .unwrap(); + assert_eq!( + code(fixture.request(Policy::Missing)), + "native_build_source" + ); +} + +#[test] +fn owner_labels_never_carry_the_incarnation() { + let label = owner_label("0123456789abcdef0123456789abcdef"); + assert_eq!(label.len(), 32); + assert!(!label.contains("0123456789abcdef")); + assert_ne!(label, owner_label("another")); +} + +/// Run guest-daemon.sh with `dockerd` replaced by a shell function that records its argv. +fn daemon_arguments(mode: &str) -> (bool, String) { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-guest-daemon"); + let script = format!( + "dockerd() {{ printf '%s\\n' \"$@\" > '{}/argv'; }}\n{}", + root.display(), + include_str!("../guest-daemon.sh") + ); + let status = std::process::Command::new("/bin/sh") + .args(["-c", &script, "daemon-test", mode]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + let argv = fs::read_to_string(root.join("argv")).unwrap_or_default(); + fs::remove_dir_all(&root).unwrap(); + (status.code() == Some(64), argv) +} + +#[test] +fn only_egress_pools_get_the_fixed_build_bridge() { + let (refused, argv) = daemon_arguments("isolated"); + assert!(!refused); + assert!(argv.lines().any(|l| l == "--bridge=none")); + assert!(!argv.contains("--bip")); + for mode in ["internet", "approved-hosts", "host-gateway"] { + let (refused, argv) = daemon_arguments(mode); + assert!(!refused, "{mode}"); + let lines: Vec<&str> = argv.lines().collect(); + assert!(lines.contains(&"--bip=10.213.255.1/24"), "{mode}"); + assert!(lines.contains(&"--icc=false"), "{mode}"); + assert!(!lines.contains(&"--bridge=none"), "{mode}"); + assert!(lines.contains(&"--iptables=true"), "{mode}"); + assert_eq!( + lines.contains(&"--host-gateway-ip=100.96.0.1"), + mode == "host-gateway" + ); + } + let (refused, argv) = daemon_arguments("bridge"); + assert!(refused); + assert!(argv.is_empty()); + assert_eq!(BUILD_NETWORK_SUBNET, "10.213.255.0/24"); +} + +#[test] +fn build_network_script_parses_and_ignores_isolated_pools() { + let script = include_str!("../guest-build-network.sh"); + let run = |args: &[&str]| { + std::process::Command::new("/bin/sh") + .args(["-c", script, "network-test"]) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .code() + }; + assert!( + std::process::Command::new("/bin/sh") + .args(["-n", "-c", script]) + .status() + .unwrap() + .success() + ); + assert_eq!(run(&["isolated", "boot"]), Some(0)); + assert_eq!(run(&["bridge", "boot"]), Some(64)); + assert_eq!(run(&[]), Some(64)); + assert!(script.contains(BUILD_NETWORK_MARKER)); +} + +#[test] +fn cold_builds_need_free_storage_and_successful_builds_prune_the_cache() { + assert_eq!(storage_required(None), 6 * GIB); + assert_eq!(storage_required(Some(GIB)), 6 * GIB); + assert_eq!(storage_required(Some(5 * GIB)), 10 * GIB); + assert_eq!(storage_required(Some(u64::MAX)), u64::MAX); + + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + *pool.available.borrow_mut() = 6 * GIB - 1; + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); + assert_eq!(fixture.calls(&inputs), 0); + + *pool.available.borrow_mut() = 6 * GIB; + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(outcome.cache_reclaimed_bytes, Some(7)); + assert_eq!(*pool.prunes.borrow(), vec![CACHE_BUDGET]); + assert_eq!(fixture.receipt(&inputs).image_bytes, Some(5 * GIB)); + + // The next cold build of this key needs twice the last verified image. + let rebuild = fixture.inputs(Policy::Rebuild); + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); + // A warm reuse needs no free-space headroom. + let reused = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(reused.reused); + assert_eq!(pool.prunes.borrow().len(), 1); + + // A rebuild that fails verifies no image: the last verified size still applies. + *pool.available.borrow_mut() = 10 * GIB; + fixture.client(&rebuild, "fail"); + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let receipt = fixture.receipt(&rebuild); + assert_eq!(receipt.phase, Phase::Failed); + assert_eq!(receipt.image_bytes, Some(5 * GIB)); + *pool.available.borrow_mut() = 6 * GIB; + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); +} + +impl reclaim::ReclaimPool for FakePool { + fn build_images(&self, _: &()) -> Result, CandidateError> { + Ok(self + .images + .borrow() + .values() + .map( + |image| serde_json::json!({"Id": image["Id"], "Labels": image["Config"]["Labels"]}), + ) + .collect()) + } + fn remove(&self, _: &(), image: &str) -> Result { + let allowed = self.removable.borrow().get(image).copied().unwrap_or(true); + if allowed { + self.removed.borrow_mut().push(image.to_owned()); + self.images.borrow_mut().remove(image); + } + Ok(allowed) + } +} + +#[test] +fn reclamation_keeps_current_referenced_in_use_and_foreign_images() { + const OLD: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + const KEPT: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + const BUSY: &str = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let current = pool.images.borrow()[IMAGE].clone(); + for id in [OLD, KEPT, BUSY] { + let mut superseded = current.clone(); + superseded["Id"] = id.into(); + pool.images.borrow_mut().insert(id.into(), superseded); + } + pool.removable.borrow_mut().insert(BUSY.into(), false); + let graphs = fixture.candidate.state_root.join("run/graphs/0123"); + state::private_directory(&graphs).unwrap(); + fs::write( + graphs.join("receipt.json"), + format!("{{\"image\":\"{KEPT}\"}}"), + ) + .unwrap(); + + // Nothing is removed while a build is admitted. + let build = Flock::try_lock( + Flock::open(&builds_root(&fixture.candidate).join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap(); + assert_eq!( + reclaim::run(&fixture.candidate, &pool).unwrap_err().code, + "provider_busy" + ); + drop(build); + + let report = reclaim::run(&fixture.candidate, &pool).unwrap(); + assert_eq!(report.removed, vec![OLD.to_owned()]); + assert_eq!(report.retained[IMAGE], reclaim::Retained::Current); + assert_eq!(report.retained[KEPT], reclaim::Retained::Referenced); + assert_eq!(report.retained[BUSY], reclaim::Retained::EngineRefused); + assert_eq!(report.cache_reclaimed_bytes, Some(7)); + // Another pool's images are never this pool's to remove. + let other = FakePool::new("pool-2"); + other + .images + .borrow_mut() + .extend(pool.images.borrow().clone()); + let report = reclaim::run(&fixture.candidate, &other).unwrap(); + assert!(report.removed.is_empty()); + assert!( + report + .retained + .values() + .all(|r| *r == reclaim::Retained::NotOwned) + ); +} diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index 909dfa5e9..1ab4f0d97 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -6,7 +6,13 @@ //! the complete stream has hashed to the same value; a file changed after validation //! cannot complete an import. Memory use is bounded by small metadata members, not by //! archive size. -use super::{engine::Engine, image_platform::ImagePlatform, image_policy::ImagePolicy, state}; +use super::{ + engine::Engine, + go_json::{GoJson, Unreadable}, + image_platform::ImagePlatform, + image_policy::ImagePolicy, + state, +}; use crate::{Candidate, CandidateError}; use flate2::read::MultiGzDecoder; use reqwest::Method; @@ -481,14 +487,33 @@ fn validated( "Image configuration does not match the expected content ID.", )); } - let config: Value = - serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; - if config["architecture"] != platform.architecture() || config["os"] != "linux" { + // Read as the engine reads it: keys match case-insensitively and a field named more + // than once refuses (see `go_json`), so these checks see what the load will use. + let config = GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + let unreadable = |reason: Unreadable| match reason { + Unreadable::Ambiguous => error("Image configuration names one field more than once."), + Unreadable::NonAsciiKey => error("Image configuration member names must be ASCII."), + }; + let field = |document: &GoJson, name: &str| -> Result { + document + .field(name) + .map_err(unreadable) + .map(|value| value.map_or(Value::Null, GoJson::to_value)) + }; + if field(&config, "architecture")? != platform.architecture() + || field(&config, "os")? != "linux" + { return Err(error( "Image architecture is incompatible with this provider.", )); } - let diffs = config["rootfs"]["diff_ids"] + let rootfs = config + .field("rootfs") + .map_err(unreadable)? + .cloned() + .unwrap_or(GoJson::Null); + let diffs = field(&rootfs, "diff_ids")?; + let diffs = diffs .as_array() .filter(|v| v.len() == manifest.layers.len()) .ok_or_else(|| error("Image layer identities are missing."))?; @@ -841,15 +866,21 @@ pub(super) mod tests { "rootfs":{"type":"layers","diff_ids":[format!("sha256:{diff}")]}}), ) .unwrap(); - let image = format!("sha256:{}", hash(&config)); + archive_with_config(&layer, &config, extra) + } + + /// A docker-archive tarball with one already compressed layer and these exact + /// configuration bytes. Returns the archive and its config content ID. + fn archive_with_config(layer: &[u8], config: &[u8], extra: bool) -> (Vec, String) { + let image = format!("sha256:{}", hash(config)); let manifest = serde_json::to_vec( &serde_json::json!([{"Config":"config.json","Layers":["layer"],"RepoTags":[]}]), ) .unwrap(); let mut archive = tar::Builder::new(Vec::new()); let mut members = vec![ - ("config.json", config.as_slice()), - ("layer", layer.as_slice()), + ("config.json", config), + ("layer", layer), ("manifest.json", manifest.as_slice()), ]; if extra { @@ -1189,6 +1220,65 @@ pub(super) mod tests { )); } + #[test] + fn archive_configuration_fields_are_read_like_the_engine_and_refuse_repetition() { + let payload = [b'x'; 2048]; + let layer = compress(Layer::Gzip, &payload); + let diff = format!("sha256:{}", hash(&payload)); + let check = |config: String| { + let (bytes, image) = archive_with_config(&layer, config.as_bytes(), false); + validate(&bytes, &image, 4096) + }; + // Case variants the engine accepts are read as it reads them. + assert!( + check(format!( + r#"{{"Architecture":"arm64","OS":"linux","RootFS":{{"type":"layers","DIFF_IDS":["{diff}"]}}}}"# + )) + .is_ok() + ); + for config in [ + format!( + r#"{{"architecture":"arm64","Architecture":"amd64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","os":"windows","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}},"RootFS":{{"diff_ids":[]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"diff_ids":["{diff}"],"Diff_IDs":[]}}}}"# + ), + ] { + assert!(check(config.clone()).is_err(), "{config}"); + } + // Non-ASCII member names (which Go may fold onto a field) refuse in the config + // and in rootfs; a real-looking configuration still validates. + for config in [ + format!( + r#"{{"arch\u0131tecture":"amd64","architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","o\u017f":"windows","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"],"diff_\u0131ds":[]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"\u212aind":"x","diff_ids":["{diff}"]}}}}"# + ), + ] { + let error = check(config.clone()).unwrap_err(); + assert!(error.message.contains("ASCII"), "{config}: {error:?}"); + } + assert!( + check(format!( + r#"{{"created":"2024-01-01T00:00:00Z","architecture":"arm64","os":"linux","config":{{"Env":["PATH=/usr/bin"],"Cmd":["/bin/sh"]}},"rootfs":{{"type":"layers","diff_ids":["{diff}"]}},"history":[{{"created_by":"ADD file:x in /"}}]}}"# + )) + .is_ok() + ); + } + #[test] fn archive_rejects_extra_members_wrong_architecture_digest_and_expansion() { let (bytes, image) = fixture("arm64", false, false); diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 72393b375..3a4db7be1 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1530,7 +1530,7 @@ fn start_pool( // Disk rejection must not keep an allocation alive. Process authority is // independent of disk adoption; this is an unclean stop, never reconciliation. let process = owner.process.as_ref().expect("process just recorded"); - let cleanup = stop_failed_boot(process, &binary(candidate)); + let cleanup = stop_verified_provider(process, &binary(candidate)); state::write( &root(candidate).join("failed-boot.json"), &json!({ @@ -1575,7 +1575,8 @@ fn start_machine( .map(drop) } -fn stop_failed_boot( +/// SIGTERM a provider whose identity is verified at the signal, then wait for it to exit. +fn stop_verified_provider( process: &identity::ProcessIdentity, binary: &Path, ) -> Result<(), CandidateError> { @@ -1588,7 +1589,7 @@ fn stop_failed_boot( if Instant::now() >= deadline { return Err(CandidateError::new( "stop_uncertain", - "Verified failed-boot provider did not stop after SIGTERM.", + "Verified provider did not stop after SIGTERM.", )); } std::thread::sleep(Duration::from_millis(50)); @@ -1822,6 +1823,21 @@ exit 43"#, } owner.save(candidate)?; engine_ping(&socket(candidate, owner, "docker.sock")?)?; + if network_mode != "isolated" { + // Build-bridge isolation is not a startup requirement: graph containers never + // join the default bridge. A failure leaves no readiness marker, and native + // image builds refuse on that pool (see `image_build::verify_build_network`). + let _ = guest( + candidate, + owner, + include_str!("guest-build-network.sh"), + &[ + network_mode, + owner.guest_boot_id.as_deref().expect("verified boot"), + ], + false, + ); + } phase(candidate, owner, "running")?; status(candidate) } @@ -1981,6 +1997,8 @@ pub fn down(candidate: &Candidate) -> Result { } artifact::verify(candidate)?; let _lock = state::Lock::acquire(&root(candidate))?; + // Builds take their shared fence only under this lease, so none can start after this. + super::image_build::require_no_active_build(candidate)?; let mut owner = Owner::load(candidate)?; super::hostname_authority::managed::stop(candidate, &owner)?; super::publication::release(candidate, &owner.token, None)?; @@ -1998,6 +2016,90 @@ pub fn down(candidate: &Candidate) -> Result { return status(candidate); } verify_live(candidate, &owner)?; + match guest_stop(candidate, &mut owner) { + Ok(()) => status(candidate), + Err(error) + if error.code == "agent_protocol_uncertain" && agent_silent(candidate, &owner)? => + { + stop_silent_guest(candidate, &mut owner)?; + status(candidate) + } + Err(error) => Err(error), + } +} + +/// How many pings, a few seconds apart, a guest agent must leave unanswered before +/// `down` or `recover` treats it as stopped responding. +const SILENT_PINGS: u32 = 3; +#[cfg(not(test))] +const SILENT_PING_TIMEOUT: Duration = Duration::from_secs(3); +#[cfg(not(test))] +const SILENT_PING_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(test)] +const SILENT_PING_TIMEOUT: Duration = Duration::from_millis(200); +#[cfg(test)] +const SILENT_PING_INTERVAL: Duration = Duration::from_millis(50); + +/// `true` when the owned guest agent answered none of [`SILENT_PINGS`] pings. +fn agent_silent(candidate: &Candidate, owner: &Owner) -> Result { + Ok(agent_silent_at(&socket(candidate, owner, "agent.sock")?)) +} + +/// Any reply, even an unexpected one, means the guest is alive and keeps the normal stop +/// path. +fn agent_silent_at(path: &Path) -> bool { + for attempt in 0..SILENT_PINGS { + if attempt > 0 { + std::thread::sleep(SILENT_PING_INTERVAL); + } + if agent::answers_ping(path, SILENT_PING_TIMEOUT) { + return false; + } + } + true +} + +/// Stop the verified provider of a guest whose agent stopped responding. Nothing in the +/// guest is quiesced or flushed, so the pool is recorded `recovered-unclean`, as after a +/// provider exit; the next start checks its retained disks as usual. The signal reaches +/// only this home's recorded provider, rechecked by PID, start time, user and executable +/// immediately before it is sent. A provider that already exited is only recorded. +fn stop_silent_guest(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { + // Checked again at the effect: the caller holds the operation lease, but a running + // build holds only its shared fence while its client runs. + require_no_build_for_silent_stop(candidate)?; + let process = owner.process.clone().ok_or_else(|| { + CandidateError::new("process_identity_unavailable", "No recorded provider.") + })?; + if identity::alive(process.pid)? { + verify_live(candidate, owner)?; + stop_verified_provider(&process, &binary(candidate))?; + } + finish_stopped(candidate, owner, "recovered-unclean") +} + +/// Refuse to stop a silent guest while a native image build is admitted. A build releases +/// the operation lease while its client runs and keeps only the shared build fence +/// (`run/image-builds/active.lock`), so the lease alone does not prove the engine is idle. +/// The fence is a `flock`: it is released when the build's processes exit, including on +/// SIGKILL, so a refusal lasts only while a build process is alive. Callers hold the +/// operation lease, under which no new build can take the fence. +fn require_no_build_for_silent_stop(candidate: &Candidate) -> Result<(), CandidateError> { + super::image_build::require_no_active_build(candidate).map_err(|error| { + if error.code == "provider_busy" { + CandidateError::new( + "provider_busy", + "A native image build is running against this guest. Finish or cancel it first (Ctrl-C the `hack up` that started it); the next attempt then stops the guest that stopped responding.", + ) + } else { + error + } + }) +} + +/// `down`'s guest-facing part: quiesce the engine, ask the guest to flush and shut down, +/// stop the provider and record the stop. +fn guest_stop(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { let before_engine = engine_was_not_started( &owner.phase, owner.guest_boot_id.as_deref(), @@ -2009,7 +2111,7 @@ pub fn down(candidate: &Candidate) -> Result { if failed_engine { guest( candidate, - &owner, + owner, r#"set -eu test "$(cat /storage/.hack-local-owner)" = "$1" test "$(cat /proc/sys/kernel/random/boot_id)" = "$2" @@ -2042,10 +2144,10 @@ sync"#, } else { "initial" }; - phase(candidate, &mut owner, "quiescing")?; + phase(candidate, owner, "quiescing")?; let quiescence = guest( candidate, - &owner, + owner, include_str!("guest-quiesce.sh"), &[ &owner.token, @@ -2064,9 +2166,9 @@ sync"#, }), )?; } - phase(candidate, &mut owner, "stopping")?; + phase(candidate, owner, "stopping")?; let response = agent::request( - &socket(candidate, &owner, "agent.sock")?, + &socket(candidate, owner, "agent.sock")?, json!({"method":"shutdown"}), Duration::from_secs(15), )?; @@ -2076,7 +2178,7 @@ sync"#, "Guest did not acknowledge flush/unmount.", )); } - verify_live(candidate, &owner)?; + verify_live(candidate, owner)?; let process = owner.process.as_ref().expect("live identity verified"); // Revalidate immediately before signalling. macOS has no Linux pidfd equivalent; // this is cooperative same-user ownership, not hostile-process containment. @@ -2092,17 +2194,17 @@ sync"#, )); } if before_engine { - finish_stopped(candidate, &mut owner, "stopped-before-engine")?; + finish_stopped(candidate, owner, "stopped-before-engine")?; } else if failed_engine { - finish_stopped(candidate, &mut owner, "stopped-after-engine-failure")?; + finish_stopped(candidate, owner, "stopped-after-engine-failure")?; } else { - finish_quiesced_stop(candidate, &mut owner)?; + finish_quiesced_stop(candidate, owner)?; // The stopped receipt is written. The `/private/tmp` HOME alias stays reserved for // this pool, so no other user can take its name; only the provider sockets the // stop proved closed are removed. - remove_closed_sockets(candidate, &owner)?; + remove_closed_sockets(candidate, owner)?; } - status(candidate) + Ok(()) } /// Restore a clean stop's HOME alias under the caller's pool lock, when host cleanup removed @@ -2378,10 +2480,20 @@ pub fn recover(candidate: &Candidate) -> Result { return status(candidate); }; if identity::alive(process.pid)? { - return Err(CandidateError::new( - "recovery_required", - "Recovery only accepts a confirmed dead recorded process. Use down for a verified live guest.", - )); + // A live provider is stopped here only when its identity verifies and its guest + // agent stopped responding; a responsive guest is stopped cleanly by `down`. + // Refuse first, without probing, while a build is admitted. + require_no_build_for_silent_stop(candidate)?; + verify_live(candidate, &owner)?; + if !agent_silent(candidate, &owner)? { + return Err(CandidateError::new( + "recovery_required", + "Recovery only accepts a confirmed dead recorded process or a guest that stopped responding. Use down for a responsive guest.", + )); + } + // The operation lease taken above stays held through the stop and its receipt. + stop_silent_guest(candidate, &mut owner)?; + return status(candidate); } finish_stopped(candidate, &mut owner, "recovered-unclean")?; status(candidate) @@ -3069,6 +3181,112 @@ printf 'verified-cache\n' ); } + /// A short private directory for a fake agent socket (`sockaddr_un` is small). + struct ShortDirectory(std::path::PathBuf); + impl ShortDirectory { + fn new() -> Self { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .unwrap() + .read_exact(&mut bytes) + .unwrap(); + let name: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let path = Path::new("/tmp").join(format!("hka-{name}")); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for ShortDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn only_a_guest_agent_that_never_starts_a_reply_counts_as_silent() { + use std::os::unix::net::UnixListener; + let directory = ShortDirectory::new(); + let path = directory.0.join("agent.sock"); + // No listener: refused connections are silence. + assert!(agent_silent_at(&path)); + let listener = UnixListener::bind(&path).unwrap(); + // Accepted but never answered, as when the guest's vsock path is saturated. + let held = std::thread::spawn(move || { + // Keep every connection open and unanswered until the probe is over. + let kept: Vec<_> = listener + .incoming() + .take(SILENT_PINGS as usize) + .map(Result::unwrap) + .collect(); + kept.len() + }); + let started = Instant::now(); + assert!(agent_silent_at(&path)); + // Each probe waits out its deadline (the last sees the fake end the connection). + assert!(started.elapsed() >= SILENT_PING_TIMEOUT * (SILENT_PINGS - 1)); + assert_eq!(held.join().unwrap(), SILENT_PINGS as usize); + fs::remove_file(&path).unwrap(); + // Any reply byte, even of an unexpected response, is an answer. + let listener = UnixListener::bind(&path).unwrap(); + let answering = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.write_all(b"?").unwrap(); + }); + assert!(!agent_silent_at(&path)); + answering.join().unwrap(); + fs::remove_file(&path).unwrap(); + // A connection the guest side ends before replying is silence. + let listener = UnixListener::bind(&path).unwrap(); + let closing = std::thread::spawn(move || { + for stream in listener.incoming().take(SILENT_PINGS as usize) { + drop(stream.unwrap()); + } + }); + assert!(agent_silent_at(&path)); + closing.join().unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_silent_guest_is_not_stopped_under_an_admitted_build() { + let root = crate::test_root::private_test_root( + &std::env::temp_dir().canonicalize().unwrap(), + "hack-silent-stop-build", + ); + let checkout = root.join("checkout"); + state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + // A live process stands in for the recorded provider; it must not be signalled. + let mut provider = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(); + let mut owner: Owner = serde_json::from_value(json!({ + "version":1,"checkout":"/fixture","token":"fixture","machine":"fixture", + "short_home":"/fixture","created":true,"phase":"running","process":null, + "storage":null,"overlay":null,"guest_boot_id":null, + "daemon_pid":null,"daemon_start":null,"rootfs_digest":null + })) + .unwrap(); + owner.process = Some(identity::observe(provider.id() as i32).unwrap()); + // An admitted build holds the shared fence, as `image_build::run` does. + let fence = super::super::image_build::hold_build_fence_for_test(&candidate); + let refused = stop_silent_guest(&candidate, &mut owner).unwrap_err(); + assert_eq!(refused.code, "provider_busy"); + assert!(refused.message.contains("Ctrl-C")); + assert!( + provider.try_wait().unwrap().is_none(), + "provider was signalled" + ); + assert_eq!(owner.phase, "running"); + drop(fence); + // Without a build, the check passes and the stop proceeds to identity checks. + assert!(require_no_build_for_silent_stop(&candidate).is_ok()); + provider.kill().unwrap(); + provider.wait().unwrap(); + let _ = fs::remove_dir_all(&root); + } + #[cfg(target_os = "macos")] #[test] fn failed_boot_cleanup_stops_only_the_verified_process() { @@ -3082,7 +3300,7 @@ printf 'verified-cache\n' .unwrap(); let observed = identity::observe(child.id() as i32).unwrap(); let reaper = std::thread::spawn(move || child.wait().unwrap()); - stop_failed_boot(&observed, &observed.executable).unwrap(); + stop_verified_provider(&observed, &observed.executable).unwrap(); reaper.join().unwrap(); assert!(!identity::alive(observed.pid).unwrap()); assert!(sentinel.try_wait().unwrap().is_none()); diff --git a/packages/runtime-core/src/provider/lifecycle/interrupted.rs b/packages/runtime-core/src/provider/lifecycle/interrupted.rs index c3bf4f4e4..84676a909 100644 --- a/packages/runtime-core/src/provider/lifecycle/interrupted.rs +++ b/packages/runtime-core/src/provider/lifecycle/interrupted.rs @@ -25,7 +25,8 @@ //! Nothing unowned is adopted, stopped or removed. use super::super::{config_audit, disk_template::Stage, identity, state}; use super::{ - Owner, binary, finish_absent, invoke, recorded_process, stop_failed_boot, verify_templates, + Owner, binary, finish_absent, invoke, recorded_process, stop_verified_provider, + verify_templates, }; use crate::{Candidate, CandidateError}; use std::fs; @@ -135,7 +136,7 @@ fn recover_start( &binary(candidate), unsafe { libc::geteuid() }, )?; - stop_failed_boot(&process, &binary(candidate))?; + stop_verified_provider(&process, &binary(candidate))?; } // The record may be stale (a previous boot's) while an orphaned start is still running. provider_idle(candidate)?; diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index af3a88c14..c6333e5b8 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -3,8 +3,11 @@ pub mod admission; mod agent; mod artifact; mod bridge; +pub mod build_policy; +pub mod build_scan; mod config_audit; mod dependency_socket; +mod go_json; pub use bridge::BridgeIntent; pub use dependency_socket::quiescent_recovery as quiescent_dependency_socket_recovery; pub use dependency_socket::recovery as dependency_socket_recovery; @@ -33,6 +36,7 @@ pub mod hostname_authority; pub mod http_probe; pub mod https_recovery; mod identity; +pub mod image_build; pub mod image_ensure; mod image_load; pub mod image_platform; @@ -148,6 +152,8 @@ pub struct Status { pub lifecycle_qualified: bool, pub artifact_prepared: bool, pub engine_prepared: bool, + /// The optional pinned BuildKit client; verified whenever its record exists. + pub builder_prepared: bool, pub memory_admission: admission::Admission, pub runtime_effects: [&'static str; 0], } @@ -169,11 +175,20 @@ pub fn probe(candidate: &Candidate) -> Result { if engine_prepared { artifact::verify_engine(candidate)?; } + let builder_prepared = candidate + .state_root + .join("providers/buildx.json") + .try_exists() + .map_err(state::io)?; + if builder_prepared { + artifact::verify_builder(candidate)?; + } Ok(Status { checkpoint: "WU02-provider-preflight", lifecycle_qualified: false, artifact_prepared: prepared, engine_prepared, + builder_prepared, memory_admission: admission::probe(&candidate.checkout)?, runtime_effects: [], }) @@ -190,5 +205,13 @@ pub fn prepare_engine( artifact::prepare_engine(candidate, archive) } +/// Prepare the pinned host BuildKit client from its exact release asset. +pub fn prepare_builder( + candidate: &Candidate, + binary: &Path, +) -> Result { + artifact::prepare_builder(candidate, binary) +} + #[cfg(test)] mod graph_probe_test; diff --git a/packages/runtime-core/src/provider/registry_credentials.rs b/packages/runtime-core/src/provider/registry_credentials.rs index 1734053b3..b4442fdbe 100644 --- a/packages/runtime-core/src/provider/registry_credentials.rs +++ b/packages/runtime-core/src/provider/registry_credentials.rs @@ -330,7 +330,7 @@ fn run_helper( "The Docker credential helper failed; its output was discarded.", )); } - if written.is_some_and(|result| result.is_err()) { + if written.as_ref().is_some_and(helper_write_failed) { return Err(error( "Cannot send the server address to the Docker credential helper.", )); @@ -415,6 +415,14 @@ pub(super) fn lookup( entry.map_or(Ok(None), file_credential) } +/// Like Go's `os/exec`, which Docker uses to run helpers, a helper that exits without +/// reading its input is not a failure: its exit status and reply decide the outcome. +fn helper_write_failed(result: &std::io::Result<()>) -> bool { + result + .as_ref() + .is_err_and(|error| error.kind() != std::io::ErrorKind::BrokenPipe) +} + #[cfg(test)] pub(super) mod tests { use super::*; @@ -639,4 +647,19 @@ pub(super) mod tests { .contains("PRIVATE_CANARY") ); } + + #[test] + fn a_helper_that_closes_its_input_early_is_judged_by_its_reply() { + use std::io::{Error, ErrorKind}; + assert!(!helper_write_failed(&Ok(()))); + assert!(!helper_write_failed(&Err(Error::from( + ErrorKind::BrokenPipe + )))); + assert!(helper_write_failed(&Err(Error::from( + ErrorKind::PermissionDenied + )))); + assert!(helper_write_failed(&Err(Error::from( + ErrorKind::Interrupted + )))); + } } diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index c2e62947a..bd46bb8c9 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -25,6 +25,7 @@ //! published. Archive and expansion sizes stay within the policy's budgets. //! - **Platform.** One unambiguous Linux ARM64 image by default; an explicit platform //! policy may select Linux AMD64 instead (see `image_platform`). +use super::go_json::GoJson; use super::image_load; use super::image_platform::{ImagePlatform, PlatformPolicy}; use super::image_policy::ImagePolicy; @@ -423,10 +424,70 @@ struct Manifest { #[serde(default)] layers: Vec, } +/// Refuse an object whose member for any of `fields` is not spelled exactly, or is +/// named more than once. The engine's Go decoder also accepts case variants and keeps +/// the last of repeated keys, while the exact-name parse below would miss or reject +/// them; refusing keeps both reading the same member. +fn canonical_members(value: &GoJson, fields: &[&str]) -> Result<(), CandidateError> { + let GoJson::Object(members) = value else { + return Ok(()); + }; + if super::go_json::ascii_keys(value).is_err() { + return Err(error("Registry manifest member names must be ASCII.")); + } + for field in fields { + let mut matching = members + .iter() + .filter(|(key, _)| super::go_json::names_field(key, field)); + let first = matching.next(); + if matching.next().is_some() || first.is_some_and(|(key, _)| key != field) { + return Err(error( + "Registry manifest member names are repeated or not in their exact case.", + )); + } + } + Ok(()) +} + +fn canonical_descriptor(value: &GoJson) -> Result<(), CandidateError> { + canonical_members(value, &["digest", "size", "mediaType", "platform", "urls"])?; + if let Ok(Some(platform)) = value.field("platform") { + canonical_members(platform, &["os", "architecture", "variant"])?; + } + Ok(()) +} + +/// The manifest or index members this module reads, checked for exact spelling. +fn canonical_manifest(bytes: &[u8]) -> Result<(), CandidateError> { + let document = GoJson::parse(bytes).map_err(|_| error("Invalid registry manifest."))?; + canonical_members( + &document, + &[ + "schemaVersion", + "mediaType", + "manifests", + "config", + "layers", + ], + )?; + for list in ["manifests", "layers"] { + if let Ok(Some(GoJson::Array(descriptors))) = document.field(list) { + for descriptor in descriptors { + canonical_descriptor(descriptor)?; + } + } + } + if let Ok(Some(config)) = document.field("config") { + canonical_descriptor(config)?; + } + Ok(()) +} + fn parse_manifest(bytes: &[u8], expected: &str) -> Result { if bytes.len() > MAX_METADATA || digest(bytes) != expected { return Err(error("Registry manifest digest or size mismatch.")); } + canonical_manifest(bytes)?; let m: Manifest = serde_json::from_slice(bytes).map_err(|_| error("Invalid registry manifest."))?; if m.schema_version != 2 { @@ -1266,8 +1327,7 @@ fn config_platform( policy: PlatformPolicy, indexed: Option, ) -> Result { - let config: serde_json::Value = - serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; + let config = platform_fields(config_bytes)?; let allowed: &[ImagePlatform] = match &indexed { Some(platform) => std::slice::from_ref(platform), None => policy.candidates(), @@ -1307,6 +1367,193 @@ pub(super) fn pinned_platform( config_platform(&config_bytes, platform, None) } +/// A base image a native build may pull, pinned to the exact manifest the registry +/// served when the build was admitted. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct BuildBase { + /// `docker.io/@`: the only form handed to the builder. + pub pinned_reference: String, + /// Digest of the manifest or index the reference resolved to. + pub source_digest: String, + /// Content ID of the selected Linux ARM64 image. + pub image_id: String, +} + +/// Resolve one normalized base-image reference for a native build, through the same +/// guarded, origin-bound client and address policy as acquisition. A tag resolves once +/// to its current manifest; a digest-pinned reference must resolve to exactly that pin. +/// Builds stay Docker Hub and Linux ARM64 only (the Dockerfile scan and the build source +/// policy admit nothing else), and the configuration must declare no `ONBUILD` +/// triggers: a stored trigger could pull images the Dockerfile scan never saw. Only +/// bounded metadata is fetched. `deadline` bounds the whole resolution, never beyond +/// the acquisition deadline. +pub fn resolve_build_base( + named: &NamedReference, + deadline: Instant, +) -> Result { + if named.docker_hub_repository().is_none() { + return Err(error( + "Only public Docker Hub [namespace/]repository[:tag][@sha256] references are supported.", + )); + } + let policy = ImagePolicy::default(); + let registry = registry(named, &policy)?; + let mut fetch = Fetch::new(®istry, &named.path, &policy)?; + fetch.deadline = fetch.deadline.min(deadline); + let (reference, expected) = match (&named.digest, &named.tag) { + (Some(pin), _) => (pin.as_str(), Some(pin.as_str())), + (None, Some(tag)) => (tag.as_str(), None), + (None, None) => ("latest", None), + }; + let initial = fetch.manifest(reference)?; + let source_digest = digest(&initial); + if expected.is_some_and(|expected| expected != source_digest) { + return Err(error("Registry manifest digest or size mismatch.")); + } + let (manifest, _, indexed) = + select(&mut fetch, &initial, &source_digest, PlatformPolicy::Native)?; + let pinned_reference = format!("{}/{}@{source_digest}", registry.domain, named.path); + // BuildKit pulls the base itself, outside acquisition's archive budget, so the sizes + // the manifest declares are bounded here before any build runs. + bounded_build_base(&manifest, &pinned_reference, policy.archive_limit)?; + let config = manifest + .config + .ok_or_else(|| error("Image config descriptor is missing."))?; + let config_bytes = fetch.object("blobs", &config)?; + config_platform(&config_bytes, PlatformPolicy::Native, indexed)?; + if declares_onbuild(&config_bytes)? { + return Err(CandidateError::new( + "native_build_base_image", + "A build base image declares ONBUILD triggers; native builds refuse stored triggers.", + )); + } + Ok(BuildBase { + pinned_reference, + source_digest, + image_id: config.digest, + }) +} + +/// Refuse a build base whose manifest declares more than `limit` compressed bytes of +/// config and layers (the per-image archive budget acquisition applies), or no layers +/// or more than the layer limit. Every descriptor's `size` is a required unsigned +/// integer (the manifest parse refuses a missing, negative or fractional one), so the +/// declared total is exact; it does not bound the unpacked size. +fn bounded_build_base( + manifest: &Manifest, + reference: &str, + limit: u64, +) -> Result<(), CandidateError> { + if manifest.layers.is_empty() || manifest.layers.len() > MAX_LAYERS { + return Err(CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} declares {} layers; native builds accept 1 to {MAX_LAYERS}. No build was run.", + manifest.layers.len() + ), + )); + } + let config = manifest + .config + .as_ref() + .ok_or_else(|| error("Image config descriptor is missing."))?; + // The same descriptor checks direct acquisition applies: BuildKit fetches these + // itself, and a foreign-layer `urls` entry or a non-distributable layer would be + // fetched from the guest's own namespace, outside the build network's isolation. + let refused = |what: &str, reason: CandidateError| { + CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} has an unsupported {what}: {} No build was run.", + reason.message + ), + ) + }; + if !config.media_type.is_empty() && !CONFIG_TYPES.contains(&config.media_type.as_str()) { + return Err(refused( + "config descriptor", + error("The manifest does not describe a container image."), + )); + } + Fetch::checked(config, MAX_METADATA as u64).map_err(|e| refused("config descriptor", e))?; + for layer in &manifest.layers { + layer_extension(&layer.media_type).map_err(|e| refused("layer", e))?; + Fetch::checked(layer, super::image_policy::ARCHIVE_CEILING) + .map_err(|e| refused("layer", e))?; + } + let declared = manifest + .layers + .iter() + .try_fold(config.size, |total, layer| total.checked_add(layer.size)); + match declared { + Some(total) if total <= limit => Ok(()), + _ => Err(CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} declares {} bytes of config and layers, more than the {limit}-byte image budget. No build was run.", + declared.map_or_else(|| "at least 2^64".to_owned(), |total| total.to_string()) + ), + )), + } +} + +fn ambiguous(reason: super::go_json::Unreadable) -> CandidateError { + match reason { + super::go_json::Unreadable::Ambiguous => error( + "Image configuration names one field more than once (keys differing only in case count as the same field).", + ), + super::go_json::Unreadable::NonAsciiKey => { + error("Image configuration member names must be ASCII.") + } + } +} + +/// The `os`, `architecture` and `variant` members of an image configuration as the +/// engine reads them (see [`super::go_json`]), as a document for +/// [`ImagePlatform::matches_config`]. A member named more than once refuses. +pub(super) fn platform_fields(config_bytes: &[u8]) -> Result { + let config = GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + if !matches!(config, GoJson::Object(_)) { + return Err(error("Invalid image configuration.")); + } + let mut fields = serde_json::Map::new(); + for name in ["os", "architecture", "variant"] { + if let Some(value) = config.field(name).map_err(ambiguous)? { + fields.insert(name.to_owned(), value.to_value()); + } + } + Ok(serde_json::Value::Object(fields)) +} + +/// Whether an image configuration carries stored `ONBUILD` triggers, read as the +/// engine and BuildKit read it: keys match case-insensitively (`onbuild`, `ONBUILD`), at +/// the top level and under `config` and the legacy `container_config`. A field named +/// more than once, or holding anything but an array or `null`, refuses. +fn declares_onbuild(config_bytes: &[u8]) -> Result { + let document = + GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + if !matches!(document, GoJson::Object(_)) { + return Err(error("Invalid image configuration.")); + } + let mut scopes = vec![&document]; + for name in ["config", "container_config"] { + match document.field(name).map_err(ambiguous)? { + None | Some(GoJson::Null) => {} + Some(object @ GoJson::Object(_)) => scopes.push(object), + Some(_) => return Err(error("Invalid image configuration.")), + } + } + let mut declared = false; + for scope in scopes { + match scope.field("OnBuild").map_err(ambiguous)? { + None | Some(GoJson::Null) => {} + Some(GoJson::Array(triggers)) => declared |= !triggers.is_empty(), + Some(_) => return Err(error("Invalid image configuration.")), + } + } + Ok(declared) +} + fn layer_extension(media_type: &str) -> Result<&'static str, CandidateError> { match media_type { "application/vnd.oci.image.layer.v1.tar+gzip" diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index dc2545268..7756b4f86 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -1597,3 +1597,374 @@ fn pinned_bun_node_image_downloads_without_docker() { .unwrap(); println!("{}", serde_json::to_string(&receipt).unwrap()); } + +#[test] +fn stored_onbuild_triggers_are_detected_and_malformed_configs_refuse() { + assert!(!declares_onbuild(br#"{"config":{}}"#).unwrap()); + assert!(!declares_onbuild(br#"{"config":{"OnBuild":null}}"#).unwrap()); + assert!(!declares_onbuild(br#"{"config":{"OnBuild":[]}}"#).unwrap()); + assert!(declares_onbuild(br#"{"config":{"OnBuild":["COPY --from=x / /"]}}"#).unwrap()); + assert!(declares_onbuild(br#"{"config":{"OnBuild":"RUN x"}}"#).is_err()); + assert!(declares_onbuild(b"not json").is_err()); +} + +#[test] +fn onbuild_triggers_are_found_under_any_key_case_the_engine_accepts() { + // The engine's Go decoder matches field names case-insensitively. + for config in [ + r#"{"config":{"onbuild":["ADD . /src"]}}"#, + r#"{"config":{"ONBUILD":["ADD . /src"]}}"#, + r#"{"config":{"oNbUiLd":["ADD . /src"]}}"#, + r#"{"CONFIG":{"OnBuild":["ADD . /src"]}}"#, + r#"{"Config":{"onBuild":["ADD . /src"]}}"#, + r#"{"container_config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"Container_Config":{"onbuild":["ADD . /src"]}}"#, + r#"{"OnBuild":["ADD . /src"],"config":{}}"#, + ] { + assert!(declares_onbuild(config.as_bytes()).unwrap(), "{config}"); + } + for config in [ + r#"{"config":{"onbuild":[]}}"#, + r#"{"config":{"ONBUILD":null},"container_config":{"OnBuild":[]}}"#, + r#"{"config":{"Env":["OnBuild=x"],"onbuilds":["x"]}}"#, + ] { + assert!(!declares_onbuild(config.as_bytes()).unwrap(), "{config}"); + } +} + +#[test] +fn onbuild_fields_named_more_than_once_or_malformed_refuse() { + // The engine keeps the last of several matching keys; a check here could see + // another, so any repetition, exact or by case, fails closed. + for config in [ + r#"{"config":{"OnBuild":[],"onbuild":["ADD . /src"]}}"#, + r#"{"config":{"OnBuild":["ADD . /src"],"OnBuild":[]}}"#, + r#"{"config":{"OnBuild":[]},"Config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"config":{},"config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"container_config":{},"CONTAINER_CONFIG":{}}"#, + r#"{"config":{"onbuild":"ADD . /src"}}"#, + r#"{"config":"ADD . /src"}"#, + r#"["not an object"]"#, + ] { + let error = declares_onbuild(config.as_bytes()).unwrap_err(); + assert_eq!(error.code, "registry_image", "{config}"); + } +} + +/// Non-ASCII spellings Go's folding can match to an ASCII field: dotless i (U+0131), +/// long s (U+017F) and the Kelvin sign (U+212A). +const NON_ASCII_KEYS: [(&str, &str); 4] = [ + ("OnBuild", "OnBu\u{131}ld"), + ("architecture", "arch\u{131}tecture"), + ("os", "o\u{17f}"), + ("kind", "\u{212a}ind"), +]; + +#[test] +fn image_configs_with_non_ascii_member_names_refuse() { + let onbuild = NON_ASCII_KEYS[0].1; + for config in [ + format!(r#"{{"config":{{"{onbuild}":["ADD . /src"]}}}}"#), + format!(r#"{{"container_config":{{"{onbuild}":["ADD . /src"]}}}}"#), + format!(r#"{{"{onbuild}":["ADD . /src"],"config":{{}}}}"#), + r#"{"conf\u0131g":{"OnBuild":["ADD . /src"]}}"#.to_owned(), + r#"{"config":{"Env":[],"\u017fhell":["/bin/sh"]}}"#.to_owned(), + ] { + let error = declares_onbuild(config.as_bytes()).unwrap_err(); + assert!(error.message.contains("ASCII"), "{config}: {error:?}"); + } + let native = |config: &str| config_platform(config.as_bytes(), PlatformPolicy::Native, None); + for (_, key) in NON_ASCII_KEYS { + let config = format!(r#"{{"os":"linux","architecture":"arm64","{key}":"amd64"}}"#); + assert!( + native(&config).unwrap_err().message.contains("ASCII"), + "{config}" + ); + } + // Real-looking OCI and Docker configurations still pass. + let oci = r#"{"created":"2024-01-01T00:00:00Z","architecture":"arm64","variant":"v8","os":"linux","config":{"Env":["PATH=/usr/local/bin:/usr/bin"],"Cmd":["/bin/sh"],"WorkingDir":"/","Labels":{"org.opencontainers.image.title":"base"},"OnBuild":null},"rootfs":{"type":"layers","diff_ids":["sha256:0000000000000000000000000000000000000000000000000000000000000000"]},"history":[{"created_by":"ADD file:x in /"}]}"#; + assert!(!declares_onbuild(oci.as_bytes()).unwrap()); + assert_eq!(native(oci).unwrap(), ImagePlatform::LinuxArm64); + let docker = r##"{"architecture":"arm64","os":"linux","config":{"Hostname":"","User":"","ExposedPorts":{"80/tcp":{}},"Env":["A=b"],"Cmd":["nginx"],"Image":"sha256:x","Volumes":null,"Entrypoint":null,"OnBuild":[],"Labels":null},"container_config":{"Cmd":["/bin/sh","-c","#(nop) CMD [\"nginx\"]"],"OnBuild":null},"docker_version":"20.10.7","rootfs":{"type":"layers","diff_ids":[]}}"##; + assert!(!declares_onbuild(docker.as_bytes()).unwrap()); + assert_eq!(native(docker).unwrap(), ImagePlatform::LinuxArm64); +} + +#[test] +fn manifests_with_non_ascii_member_names_refuse() { + let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + let digest_a = format!("sha256:{}", "a".repeat(64)); + // A real-looking OCI index, and a Docker v2 manifest, pass. + let index = format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{digest_a}","size":1234,"platform":{{"architecture":"arm64","os":"linux","variant":"v8"}},"annotations":{{"org.opencontainers.image.ref.name":"1"}}}}]}}"# + ); + assert!(parse(&index).is_ok()); + let manifest = format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{{"mediaType":"application/vnd.docker.container.image.v1+json","size":1469,"digest":"{digest_a}"}},"layers":[{{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":3623807,"digest":"{digest_a}"}}]}}"# + ); + assert!(parse(&manifest).is_ok()); + for (from, to) in [ + // top-level index and manifest objects + ("\"manifests\"", "\"man\u{131}fests\""), + ("\"schemaVersion\"", "\"\u{17f}chemaVersion\""), + // descriptor + ("\"digest\"", "\"d\u{131}gest\""), + // platform + ("\"architecture\"", "\"arch\u{131}tecture\""), + ("\"os\"", "\"o\u{17f}\""), + ] { + let variant = index.replace(from, to); + assert_ne!(variant, index); + let error = parse(&variant).unwrap_err(); + assert!(error.message.contains("ASCII"), "{variant}: {error:?}"); + } + for (from, to) in [ + ("\"layers\"", "\"\u{212a}layers\""), + ("\"size\":1469", "\"\u{17f}ize\":1469"), + ] { + let variant = manifest.replace(from, to); + assert_ne!(variant, manifest); + assert!( + parse(&variant).unwrap_err().message.contains("ASCII"), + "{variant}" + ); + } +} + +/// A single-image manifest whose config and layers declare these sizes. +fn sized_manifest(config: &str, layers: &[&str]) -> String { + let digest_a = format!("sha256:{}", "a".repeat(64)); + let layers: Vec = layers + .iter() + .map(|size| { + format!( + r#"{{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"{digest_a}","size":{size}}}"# + ) + }) + .collect(); + format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{digest_a}","size":{config}}},"layers":[{}]}}"#, + layers.join(",") + ) +} + +#[test] +fn build_bases_are_bounded_by_the_image_budget_before_the_build() { + let reference = format!("docker.io/library/alpine@sha256:{}", "b".repeat(64)); + let manifest = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())); + let limit = ImagePolicy::default().archive_limit; + // A normal base passes, also exactly at the budget. + let normal = manifest(&sized_manifest("1469", &["3623807", "1024"])).unwrap(); + assert!(bounded_build_base(&normal, &reference, limit).is_ok()); + let exact = limit - 100; + let at_budget = manifest(&sized_manifest("100", &[&exact.to_string()])).unwrap(); + assert!(bounded_build_base(&at_budget, &reference, limit).is_ok()); + // Over the budget, including by overflow: refused naming the base and the total. + let over = manifest(&sized_manifest("101", &[&exact.to_string()])).unwrap(); + let error = bounded_build_base(&over, &reference, limit).unwrap_err(); + assert_eq!(error.code, "native_build_base_image"); + assert!(error.message.contains(&reference), "{error:?}"); + assert!( + error.message.contains(&(limit + 1).to_string()), + "{error:?}" + ); + assert!(error.message.contains(&limit.to_string()), "{error:?}"); + let huge = u64::MAX.to_string(); + let overflow = manifest(&sized_manifest("1", &[&huge])).unwrap(); + // A layer beyond the acquisition ceiling refuses as a descriptor before any sum. + let error = bounded_build_base(&overflow, &reference, limit).unwrap_err(); + assert_eq!(error.code, "native_build_base_image"); + assert!(error.message.contains("size limits"), "{error:?}"); + // Too many layers, or none. + let many: Vec<&str> = vec!["1"; MAX_LAYERS + 1]; + let error = bounded_build_base( + &manifest(&sized_manifest("1", &many)).unwrap(), + &reference, + limit, + ) + .unwrap_err(); + assert!( + error + .message + .contains(&format!("{} layers", MAX_LAYERS + 1)), + "{error:?}" + ); + assert!( + bounded_build_base( + &manifest(&sized_manifest("1", &[])).unwrap(), + &reference, + limit + ) + .is_err() + ); +} + +#[test] +fn build_base_descriptors_are_validated_like_direct_acquisition() { + let reference = format!("docker.io/library/alpine@sha256:{}", "b".repeat(64)); + let digest_a = format!("sha256:{}", "a".repeat(64)); + let limit = ImagePolicy::default().archive_limit; + let check = |config_type: &str, layer: &str| { + let text = format!( + r#"{{"schemaVersion":2,"config":{{"mediaType":"{config_type}","digest":"{digest_a}","size":1469}},"layers":[{layer}]}}"# + ); + let manifest = parse_manifest(text.as_bytes(), &digest(text.as_bytes())).unwrap(); + bounded_build_base(&manifest, &reference, limit) + }; + let layer = |media_type: &str, extra: &str| { + format!(r#"{{"mediaType":"{media_type}","digest":"{digest_a}","size":1024{extra}}}"#) + }; + let docker_config = "application/vnd.docker.container.image.v1+json"; + let oci_config = "application/vnd.oci.image.config.v1+json"; + // Normal Docker v2 and OCI layers pass. + for (config_type, media_type) in [ + ( + docker_config, + "application/vnd.docker.image.rootfs.diff.tar.gzip", + ), + (oci_config, "application/vnd.oci.image.layer.v1.tar+gzip"), + (oci_config, "application/vnd.oci.image.layer.v1.tar+zstd"), + (oci_config, "application/vnd.oci.image.layer.v1.tar"), + ] { + assert!( + check(config_type, &layer(media_type, "")).is_ok(), + "{media_type}" + ); + } + // An empty `urls` is treated as absent, as direct acquisition does. + assert!( + check( + oci_config, + &layer( + "application/vnd.oci.image.layer.v1.tar+gzip", + r#","urls":[]"# + ) + ) + .is_ok() + ); + for (config_type, bad_layer) in [ + // Foreign-layer URLs. + ( + oci_config, + layer( + "application/vnd.oci.image.layer.v1.tar+gzip", + r#","urls":["http://100.96.0.1:8080/layer"]"#, + ), + ), + // Foreign and non-distributable layer types. + ( + docker_config, + layer( + "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", + "", + ), + ), + ( + oci_config, + layer( + "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip", + "", + ), + ), + // Unknown layer type, and an invalid layer digest. + (oci_config, layer("application/vnd.example.layer", "")), + ( + oci_config, + r#"{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"md5:x","size":1}"# + .to_owned(), + ), + // A config descriptor that is not an image configuration. + ( + "application/vnd.example.config+json", + layer("application/vnd.oci.image.layer.v1.tar", ""), + ), + ] { + let error = check(config_type, &bad_layer).unwrap_err(); + assert_eq!(error.code, "native_build_base_image", "{bad_layer}"); + assert!(error.message.contains(&reference), "{error:?}"); + assert!(!error.message.contains("100.96.0.1"), "{error:?}"); + } +} + +#[test] +fn descriptor_sizes_must_be_present_unsigned_integers() { + let manifest = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + assert!(manifest(&sized_manifest("1469", &["1024"])).is_ok()); + for size in ["-1", "1.5", "\"1024\"", "null", "1e3"] { + assert!( + manifest(&sized_manifest("1469", &[size])).is_err(), + "layer {size}" + ); + assert!( + manifest(&sized_manifest(size, &["1024"])).is_err(), + "config {size}" + ); + } + let missing = sized_manifest("1469", &["1024"]).replace(r#","size":1024"#, ""); + assert!(manifest(&missing).is_err()); +} + +#[test] +fn manifest_members_must_be_spelled_exactly_once() { + let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + let descriptor = format!( + r#"{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:{}","size":2,"platform":{{"os":"linux","architecture":"arm64"}}}}"#, + "a".repeat(64) + ); + let index = format!(r#"{{"schemaVersion":2,"manifests":[{descriptor}]}}"#); + assert!(parse(&index).is_ok()); + for variant in [ + index.replace("\"manifests\"", "\"Manifests\""), + index.replace("\"schemaVersion\"", "\"SchemaVersion\""), + index.replace("\"digest\"", "\"Digest\""), + index.replace("\"architecture\"", "\"ARCHITECTURE\""), + index.replace(r#""os":"linux""#, r#""os":"linux","OS":"windows""#), + format!(r#"{{"schemaVersion":2,"manifests":[{descriptor}],"MANIFESTS":[]}}"#), + format!( + r#"{{"schemaVersion":2,"manifests":[{descriptor}],"config":{descriptor},"Config":{descriptor}}}"# + ), + ] { + let error = parse(&variant).unwrap_err(); + assert!(error.message.contains("exact case"), "{variant}: {error:?}"); + } +} + +#[test] +fn image_platform_fields_are_read_like_the_engine_and_refuse_repetition() { + let native = |config: &str| config_platform(config.as_bytes(), PlatformPolicy::Native, None); + // A case variant the engine accepts is read, not missed. + assert_eq!( + native(r#"{"OS":"linux","Architecture":"arm64"}"#).unwrap(), + ImagePlatform::LinuxArm64 + ); + assert!(native(r#"{"OS":"linux","Architecture":"amd64"}"#).is_err()); + // A repeated field could mean another platform to the engine: refused. + for config in [ + r#"{"os":"linux","architecture":"arm64","Architecture":"amd64"}"#, + r#"{"os":"linux","architecture":"arm64","architecture":"amd64"}"#, + r#"{"os":"linux","OS":"windows","architecture":"arm64"}"#, + r#"{"os":"linux","architecture":"arm64","variant":"v8","VARIANT":"v7"}"#, + ] { + let error = native(config).unwrap_err(); + assert!( + error.message.contains("more than once"), + "{config}: {error:?}" + ); + } +} + +#[test] +fn build_bases_accept_only_docker_hub_references() { + let named = |value: &str| match normalize(value).unwrap() { + ImageReference::Named(named) => named, + ImageReference::ImageId(_) => unreachable!(), + }; + for value in [ + "ghcr.io/example/app:1", + "registry.test:5000/app:1", + "localhost/app:1", + ] { + let error = resolve_build_base(&named(value), Instant::now()).unwrap_err(); + assert_eq!(error.code, "registry_image", "{value}"); + } +} diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 92d501697..8e5b2c1d5 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1837,6 +1837,42 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "native-build-retention", + module: "BuildRetention", + states: 6181, + invariant: "NoReclaimReferenced", + action: "Reclaim", + fields: ["refs = {1}", "removed = 1", "fence = FALSE"], + additionalControls: [ + { + name: "foreign-adoption", + negative: true, + invariant: "NoForeignAdoption", + action: "CompleteBuild", + fields: [ + 'receipt = [phase |-> "verified", image |-> 2, inc |-> 1]', + "owner = <<1, 0, 0>>", + ], + alternativeWitnesses: [ + { + action: "CompleteBuild", + fields: [ + 'receipt = [phase |-> "verified", image |-> 1, inc |-> 1]', + "owner = <<0, 1, 0>>", + ], + }, + ], + }, + { + name: "down-under-build", + negative: true, + invariant: "DownNotUnderBuild", + action: "Down", + fields: ['build = "running"', "engineUp = FALSE", "fence = TRUE"], + }, + ], + }, { name: "relay-fence", module: "RelayFence", diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts new file mode 100644 index 000000000..b0d22ee7c --- /dev/null +++ b/src/backends/native-project-builds.ts @@ -0,0 +1,723 @@ +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + NativeRuntimeRequestError, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +/** + * Native Compose `build:` services (feature-gated by `HACK_NATIVE_BUILDS=1`). + * + * Admission is pure and runs before any hook or VM effect: it accepts the Compose + * forms the native runtime builds (`build: ` or a mapping of `context`, + * `dockerfile`, `target` and literal `args`) and refuses everything else with a + * closed, coded refusal. After the runtime is up, each distinct build runs once + * through `runtime build-image`; the service is then pinned to the returned content + * ID, so review, graph admission, receipts and restore treat it as an image + * service. Restore and restart never build: they use the retained content ID or + * refuse before any graph effect. + */ + +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const BUILD_KEY = /^[a-f0-9]{64}$/; +const ARGUMENT = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +/** + * The Compose `build` fields native builds implement. The Compose-backend feature + * gate admits exactly these fields when builds are enabled, so a build it lets + * through reaches this module's own admission and secret check. + */ +export const NATIVE_BUILD_FIELDS = [ + "context", + "dockerfile", + "target", + "args", +] as const; +const BUILD_KEYS: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); +const MAX_ARGS = 64; +/** The runtime's own budget is 30 minutes; the client waits a little longer. */ +const BUILD_TIMEOUT_SECONDS = 30 * 60; +/** After SIGTERM the runtime stops its build client within ten seconds. */ +const CANCEL_GRACE_MS = 20_000; +const MAX_STDOUT = 64 * 1024; +const ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/; + +/** Whether native `up` may build Compose `build:` services. */ +export function nativeBuildsEnabled( + env: Readonly> = process.env +): boolean { + return env.HACK_NATIVE_BUILDS === "1"; +} + +/** Whether `HACK_NATIVE_REBUILD=1` asks every build to run again. */ +export function nativeRebuildRequested( + env: Readonly> = process.env +): boolean { + return env.HACK_NATIVE_REBUILD === "1"; +} + +/** One admitted build: paths relative to the project root and the context. */ +export interface NativeBuildRequest { + readonly context: string; + readonly dockerfile: string; + readonly target: string | null; + /** Literal values. Never log, print or persist outside the private request file. */ + readonly args: Readonly>; +} + +/** The refusal for the first `build:` service while native builds are not enabled. */ +export function nativeBuildsDisabledRefusal(service: string): string { + return `Native foreground up cannot build service ${service}: it builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started.`; +} + +/** "service a", or "services a and b", for build refusals. */ +function serviceList(services: readonly string[]): string { + if (services.length <= 1) { + return `service ${services[0] ?? ""}`; + } + return `services ${services.slice(0, -1).join(", ")} and ${services.at(-1)}`; +} + +/** The admission refusal for one service. Authored values are never included. */ +function refused(service: string, detail: string): HackCliError { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native foreground up cannot build service ${service}: ${detail} Values were omitted; no hook, VM or build was started.`, + }); +} + +function literal(service: string, value: unknown, what: string): string { + if (typeof value !== "string" || value.length === 0 || value.includes("$")) { + throw refused( + service, + `${what} must be a literal non-empty string without Compose interpolation.` + ); + } + return value; +} + +function argumentEntries(service: string, value: unknown): [string, unknown][] { + if (isRecord(value)) { + return Object.entries(value); + } + if (!Array.isArray(value)) { + throw refused(service, "build args must be a mapping or a list."); + } + return value.map((item: unknown): [string, unknown] => { + if (typeof item !== "string" || !item.includes("=")) { + throw refused( + service, + "list-form build args must be KEY=VALUE literals; inheriting a host variable is not supported." + ); + } + const equal = item.indexOf("="); + return [item.slice(0, equal), item.slice(equal + 1)]; + }); +} + +function buildArguments( + service: string, + value: unknown +): Record { + if (value === undefined) { + return {}; + } + const entries = argumentEntries(service, value); + if (entries.length > MAX_ARGS) { + throw refused(service, "a native build accepts at most 64 build args."); + } + // A null prototype keeps every admitted name an own property, `__proto__` included. + const result: Record = Object.create(null); + for (const [name, raw] of entries) { + if (!ARGUMENT.test(name) || Object.hasOwn(result, name)) { + throw refused(service, "build arg names must be unique identifiers."); + } + const text = + typeof raw === "number" && Number.isFinite(raw) ? String(raw) : raw; + if (typeof text !== "string" || text.includes("$")) { + // Env-derived values would be baked into image history. + throw refused( + service, + "build arg values must be literal strings or numbers; interpolation and null values are not supported." + ); + } + result[name] = text; + } + return result; +} + +/** The declared build fields with Compose defaults, before path checks. */ +function buildFields(service: string, build: unknown): NativeBuildRequest { + if (typeof build === "string") { + return { + context: literal(service, build, "the build context"), + dockerfile: "Dockerfile", + target: null, + args: {}, + }; + } + if (!isRecord(build)) { + throw refused(service, "build must be a context path or a mapping."); + } + if (Object.keys(build).some((key) => !BUILD_KEYS.has(key))) { + throw refused( + service, + "only build context, dockerfile, target and literal args are supported." + ); + } + const target = + build.target === undefined + ? null + : literal(service, build.target, "the build target"); + if (target !== null && !TARGET.test(target)) { + throw refused(service, "the build target must be a stage name."); + } + return { + context: + build.context === undefined + ? "." + : literal(service, build.context, "the build context"), + dockerfile: + build.dockerfile === undefined + ? "Dockerfile" + : literal(service, build.dockerfile, "the Dockerfile path"), + target, + args: buildArguments(service, build.args), + }; +} + +/** + * Admit one service's `build` declaration, or return `undefined` when it has none. + * Paths follow Compose: the context is relative to the Compose file's directory and + * the Dockerfile is relative to the context. The result is project-relative; the + * runtime resolves it again without following symlinks. + */ +export function admitNativeProjectBuild(opts: { + /** The Compose service name; every refusal names it. */ + readonly name: string; + readonly service: Readonly>; + readonly projectRoot: string; + readonly composeFile: string; +}): NativeBuildRequest | undefined { + const build = opts.service.build; + if (build === undefined) { + return; + } + const name = opts.name; + if (Object.hasOwn(opts.service, "image")) { + throw refused(name, "a service may not declare both image and build."); + } + const { context, dockerfile, target, args } = buildFields(name, build); + if (context.includes("://") || context.startsWith("git@")) { + throw refused(name, "remote build contexts are not supported."); + } + if (isAbsolute(dockerfile)) { + throw refused( + name, + "the Dockerfile must be relative to the build context." + ); + } + const absolute = resolve(dirname(opts.composeFile), context); + const root = resolve(opts.projectRoot); + const inside = relative(root, absolute); + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + throw refused(name, "the build context must be inside the project."); + } + return { context: inside === "" ? "." : inside, dockerfile, target, args }; +} + +/** + * Admit every build service of a normalized Compose document. Throws the closed + * refusal for any unsupported form, and when builds are not enabled. + */ +export function admitNativeProjectBuilds(opts: { + readonly services: Readonly< + Record>> + >; + readonly projectRoot: string; + readonly composeFile: string; + readonly enabled: boolean; +}): ReadonlyMap { + const builds = new Map(); + for (const [name, service] of Object.entries(opts.services)) { + const request = admitNativeProjectBuild({ ...opts, name, service }); + if (request === undefined) { + continue; + } + if (!opts.enabled) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: nativeBuildsDisabledRefusal(name), + }); + } + builds.set(name, request); + } + return builds; +} + +/** The result of one `runtime build-image`, checked against its closed shape. */ +export interface NativeBuiltImage { + readonly imageId: string; + readonly buildKey: string; + readonly reused: boolean; +} + +function parseBuilt(value: unknown): NativeBuiltImage { + if ( + !( + isRecord(value) && + typeof value.image_id === "string" && + IMAGE_ID.test(value.image_id) && + typeof value.build_key === "string" && + BUILD_KEY.test(value.build_key) && + typeof value.reused === "boolean" + ) + ) { + throw new ClosedCliError( + "Native runtime returned an invalid image build result; values omitted." + ); + } + return { + imageId: value.image_id, + buildKey: value.build_key, + reused: value.reused, + }; +} + +/** + * Forward build output to stderr as it arrives, holding back a final structured + * runtime error line so it can be returned instead of printed. + */ +async function forwardBuildOutput( + stream: ReadableStream, + write: (text: string) => void +): Promise { + const decoder = new TextDecoder(); + let pending = ""; + let held: string | undefined; + const emit = (line: string) => { + if (held !== undefined) { + write(`${held}\n`); + held = undefined; + } + if (line.startsWith('{"code":')) { + held = line; + } else { + write(`${line}\n`); + } + }; + const reader = stream.getReader(); + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) { + break; + } + pending += decoder.decode(chunk, { stream: true }); + let newline = pending.indexOf("\n"); + while (newline >= 0) { + emit(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf("\n"); + } + } + pending += decoder.decode(); + if (pending.length > 0) { + emit(pending); + } + return held; +} + +function failureCode(line: string | undefined): string | undefined { + if (line === undefined) { + return; + } + try { + const value: unknown = JSON.parse(line); + return isRecord(value) && + typeof value.code === "string" && + ERROR_CODE.test(value.code) + ? value.code + : undefined; + } catch { + return; + } +} + +function failureMessage(line: string | undefined): string | undefined { + try { + const value: unknown = line === undefined ? undefined : JSON.parse(line); + return isRecord(value) && typeof value.message === "string" + ? value.message.slice(0, 512) + : undefined; + } catch { + return; + } +} + +/** + * Run `runtime build-image` with build output streamed to stderr. Cancellation sends + * SIGTERM so the runtime can stop its build client's process group, then SIGKILL + * after a grace period. The request file path is the only argument; it carries the + * argument values and is private to this invocation. + */ +export async function invokeNativeBuild(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly requestFile: string; + readonly signal?: AbortSignal; + readonly write?: (text: string) => void; +}): Promise { + if (opts.signal?.aborted) { + throw new ClosedCliError( + "Native foreground up was canceled; values omitted." + ); + } + const child = Bun.spawn( + [ + opts.runtime.binary, + "--candidate-root", + opts.runtime.home, + "runtime", + "build-image", + "--request", + opts.requestFile, + "--json", + ], + { + cwd: opts.cwd, + env: systemEnvironment(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + let killTimer: ReturnType | undefined; + let canceled = false; + const cancel = () => { + canceled = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), CANCEL_GRACE_MS); + }; + opts.signal?.addEventListener("abort", cancel, { once: true }); + const outer = setTimeout(cancel, (BUILD_TIMEOUT_SECONDS + 60) * 1000); + try { + const [stdout, held, code] = await Promise.all([ + new Response(child.stdout).arrayBuffer(), + forwardBuildOutput( + child.stderr, + opts.write ?? ((text) => process.stderr.write(text)) + ), + child.exited, + ]); + if (canceled) { + throw new ClosedCliError( + "Native foreground up was canceled during an image build; no image was adopted." + ); + } + if (code !== 0) { + const nativeCode = failureCode(held); + throw new NativeRuntimeRequestError({ + message: + failureMessage(held) ?? + "Native image build failed; its output is shown above.", + nativeCode, + }); + } + if (stdout.byteLength > MAX_STDOUT) { + throw new ClosedCliError( + "Native runtime returned an oversized image build result; values omitted." + ); + } + return parseBuilt(JSON.parse(new TextDecoder().decode(stdout))); + } finally { + opts.signal?.removeEventListener("abort", cancel); + clearTimeout(outer); + clearTimeout(killTimer); + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } +} + +/** The same minimal process environment ordinary runtime requests receive. */ +function systemEnvironment(): Record { + const env: Record = {}; + for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + return env; +} + +/** + * Whether a service runs for the selected profiles, as in Compose `up`: a service with + * no `profiles` always runs; otherwise one of its profiles must be selected. A + * malformed `profiles` field counts as active, so it is built and reviewed. + */ +export function nativeServiceActive( + service: Readonly>, + profiles: readonly string[] +): boolean { + const declared = service.profiles; + if (declared === undefined) { + return true; + } + if (!Array.isArray(declared) || declared.length === 0) { + return true; + } + return declared.some( + (profile) => typeof profile === "string" && profiles.includes(profile) + ); +} + +/** + * Refuse, before any hook, VM or build effect, a build argument whose value equals a + * secret value Hack manages for this project and environment. Build arguments are + * not secrets (an image records them in its history), so a managed secret must not + * travel as one. The comparison is in memory; no value is ever shown. + */ +export function refuseEmulatedBuilds(opts: { + readonly builds: ReadonlyMap; + readonly amd64Emulation?: Readonly>; +}): void { + for (const service of opts.builds.keys()) { + if (opts.amd64Emulation?.[service] === "amd64") { + throw refused( + service, + "native builds are linux/arm64 only, and the native adaptation selects amd64 emulation for it. Use prefer-native or remove it from amd64Emulation." + ); + } + } +} + +/** + * Refuse, before any hook, VM or build effect, a build argument whose value equals a + * secret value Hack manages for this project and environment (see below). + */ +export function refuseManagedSecretBuildArguments(opts: { + readonly builds: ReadonlyMap; + readonly managedSecretValues: readonly string[]; +}): void { + if (opts.managedSecretValues.length === 0) { + return; + } + const secrets = new Set(opts.managedSecretValues.filter((v) => v !== "")); + for (const [service, request] of opts.builds) { + for (const [name, value] of Object.entries(request.args)) { + if (secrets.has(value)) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native foreground up cannot build service ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, + }); + } + } + } +} + +/** A refusal when a retained run's built image is gone: restore never builds. */ +function retainedMissing(service: string): ClosedCliError { + return new ClosedCliError( + `The retained built image for ${service} was removed; start fresh instead of restoring. Nothing was built.` + ); +} + +/** + * Build (or reuse) every admitted build service in the selected profiles and pin it to + * its content ID. As with Compose `up`, a build service outside the selected profiles + * is neither built nor pinned: it keeps its `build` declaration, which review accepts + * for inactive services, so restore and restart never need an image for it. Identical + * declarations build once. Retained content IDs always win, and a restore with a + * missing retained image refuses instead of building. + */ +export async function buildNativeProjectImages(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly branch: string | null | undefined; + readonly profiles: readonly string[]; + readonly builds: ReadonlyMap; + readonly specs: Record>; + readonly retainedImages: ReadonlyMap; + readonly restore: boolean; + readonly rebuild: boolean; + readonly signal: AbortSignal; + readonly invoke?: typeof invokeNativeBuild; +}): Promise { + const pending = pendingBuilds(opts); + if (pending.size === 0) { + return; + } + await runPendingBuilds({ ...opts, pending }); +} + +type PendingBuild = { request: NativeBuildRequest; services: string[] }; + +/** Pin retained images, refuse a restore without one, and group identical builds. */ +function pendingBuilds(opts: { + readonly profiles: readonly string[]; + readonly builds: ReadonlyMap; + readonly specs: Record>; + readonly retainedImages: ReadonlyMap; + readonly restore: boolean; +}): Map { + const pending = new Map(); + for (const [service, request] of opts.builds) { + const spec = opts.specs[service]; + if (!spec) { + throw new ClosedCliError( + "Native build services changed during startup; values omitted." + ); + } + if (!nativeServiceActive(spec, opts.profiles)) { + continue; + } + const retained = opts.retainedImages.get(service); + if (retained !== undefined) { + pin(spec, retained); + continue; + } + // A retained run selects retained images only while its configuration is + // unchanged; then a build service without one refuses instead of building. + if (opts.restore && opts.retainedImages.size > 0) { + throw retainedMissing(service); + } + const identity = JSON.stringify([ + request.context, + request.dockerfile, + request.target, + Object.entries(request.args).sort(([a], [b]) => (a < b ? -1 : 1)), + ]); + const entry = pending.get(identity) ?? { request, services: [] }; + entry.services.push(service); + pending.set(identity, entry); + } + return pending; +} + +async function runPendingBuilds(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly branch: string | null | undefined; + readonly pending: ReadonlyMap; + readonly specs: Record>; + readonly rebuild: boolean; + readonly signal: AbortSignal; + readonly invoke?: typeof invokeNativeBuild; +}): Promise { + const invoke = opts.invoke ?? invokeNativeBuild; + // The runtime requires the exact canonical root; it also derives the namespace. + const projectRoot = await realpath(opts.projectRoot); + const directory = await mkdtemp(join(tmpdir(), "hack-native-build-")); + try { + let index = 0; + for (const { request, services } of opts.pending.values()) { + if (opts.signal.aborted) { + throw new ClosedCliError( + "Native foreground up was canceled; values omitted." + ); + } + const requestFile = join(directory, `request-${index}.json`); + index += 1; + await writeFile( + requestFile, + JSON.stringify({ + version: 1, + project_root: projectRoot, + branch: opts.branch ?? null, + context: request.context, + dockerfile: request.dockerfile, + target: request.target, + args: request.args, + policy: opts.rebuild ? "rebuild" : "missing", + timeout_seconds: BUILD_TIMEOUT_SECONDS, + }), + { mode: 0o600, flag: "wx" } + ); + const built = await invoke({ + runtime: opts.runtime, + cwd: opts.projectRoot, + requestFile, + signal: opts.signal, + }).catch((error: unknown) => { + throw nameBuildFailure(services, error); + }); + // The request carries argument values; remove it as soon as it is consumed. + await rm(requestFile, { force: true }); + for (const service of services) { + const spec = opts.specs[service]; + if (spec) { + pin(spec, built.imageId); + } + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +/** + * Runtime refusal codes from the native build source policy: what the runtime + * accepts as build input and as the built image, each with what to change. The + * runtime checks them before or right after the build; they are not failures of + * the build itself. + */ +const BUILD_SOURCE_POLICY: Readonly> = { + native_build_source: + "Keep the build context and Dockerfile inside the project as regular files reached without symlinks.", + native_build_dockerfile: "Change the Dockerfile line it names.", + native_build_base_image: + "Use public Docker Hub images in the Dockerfile's FROM and COPY --from.", + native_build_argument: + "Change the build args in the service's Compose build.", + native_build_reserved_label: + "Remove reserved ownership labels from the Dockerfile and its base images.", + native_build_image_volumes: + "Remove VOLUME from the Dockerfile and its base images; declare volumes in Compose instead.", +}; + +/** + * Name the services of a failed `runtime build-image`. A refusal from the build + * source policy says so and what to change; any other runtime failure keeps its + * message. The native code is kept for callers that classify failures. + * Cancellation and closed client errors carry no service-specific cause and pass + * through unchanged. + */ +function nameBuildFailure( + services: readonly string[], + error: unknown +): unknown { + if (!(error instanceof NativeRuntimeRequestError)) { + return error; + } + const which = serviceList(services); + const code = error.nativeCode; + const change = + code !== undefined && Object.hasOwn(BUILD_SOURCE_POLICY, code) + ? BUILD_SOURCE_POLICY[code] + : undefined; + const message = + change === undefined + ? `Native foreground up could not build ${which}: ${error.message}` + : `Native foreground up cannot build ${which}: the native build source policy refused it. ${error.message} ${change} No image was adopted.`; + return new NativeRuntimeRequestError({ + message, + nativeCode: error.nativeCode, + nativeCauseCode: error.nativeCauseCode, + nativeStopFailures: error.nativeStopFailures, + }); +} + +function pin(spec: Record, image: string): void { + if (!IMAGE_ID.test(image)) { + throw new ClosedCliError( + "Native build image selection is invalid; values omitted." + ); + } + spec.image = image; + Reflect.deleteProperty(spec, "build"); +} diff --git a/src/backends/native-project-image-admission.ts b/src/backends/native-project-image-admission.ts index 835792fd9..a85318b32 100644 --- a/src/backends/native-project-image-admission.ts +++ b/src/backends/native-project-image-admission.ts @@ -35,6 +35,27 @@ function canceled(): ClosedCliError { ); } +/** Distinct registry references to admit; content IDs and admitted builds need none. */ +function imageReferences( + specs: Readonly>>> +): Set { + const references = new Set(); + for (const spec of Object.values(specs)) { + // An admitted build service has no image yet; its base images are pinned + // by the runtime build itself. + if (spec.image === undefined && spec.build !== undefined) { + continue; + } + if (typeof spec.image !== "string") { + throw refused(); + } + if (!IMAGE_ID.test(spec.image)) { + references.add(spec.image); + } + } + return references; +} + /** * Admit every authored service image before any VM, provider or image effect. * @@ -58,15 +79,7 @@ export async function admitNativeProjectImages(opts: { }): Promise { const invoke = opts.invoke ?? invokeNativeRuntime; const policy = nativeImagePolicyArgs(opts.env); - const references = new Set(); - for (const spec of Object.values(opts.specs)) { - if (typeof spec.image !== "string") { - throw refused(); - } - if (!IMAGE_ID.test(spec.image)) { - references.add(spec.image); - } - } + const references = imageReferences(opts.specs); for (const reference of references) { if (opts.signal?.aborted) { throw canceled(); diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index 7fc465ece..ea427df84 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -37,6 +37,11 @@ export type NativeProjectInput = { readonly managedFiles?: Readonly< Record >; + /** + * Private in-memory only: the values Hack stores as secrets for this project and + * environment, so build arguments can refuse to carry one. Never log or write it. + */ + readonly managedSecretValues?: readonly string[]; /** Private lifecycle environment; not container environment. */ readonly lifecycleHostEnvironment: Readonly>; /** @@ -208,6 +213,38 @@ function normalizeManagedEnvironment( return managedEnvironment; } +/** Every value the resolved project env stores as a secret, for any scope or target. */ +function managedSecretValues( + resolved: Awaited> +): string[] { + if (!resolved) { + return []; + } + const values = new Set(); + const collect = ( + metadata: typeof resolved.effectiveMetadata, + valuesFor: (target: string) => Readonly> | undefined + ) => { + for (const [target, keys] of Object.entries(metadata)) { + const resolvedValues = valuesFor(target); + for (const [key, entry] of Object.entries(keys)) { + const value = resolvedValues?.[key]; + if (entry.secret && typeof value === "string" && value !== "") { + values.add(value); + } + } + } + }; + collect(resolved.effectiveMetadata, (target) => + target === "global" ? resolved.globalEnv : resolved.serviceEnv[target] + ); + collect( + resolved.hostEffectiveMetadata, + (target) => resolved.hostTargetEnv[target] + ); + return [...values]; +} + /** * Services the reviewed native plan marks active for the selected profiles. A * malformed plan, or a service absent from it, selects nothing. @@ -322,6 +359,7 @@ export async function prepareNativeProjectInput(opts: { originalSha256: createHash("sha256").update(bytes).digest("hex"), normalizedComposeJson, managedEnvironment, + managedSecretValues: managedSecretValues(resolved), lifecycleHostEnvironment: resolved ? selectProjectEnvValuesForExecutionTarget({ resolved, diff --git a/src/backends/native-project-mapping-command.ts b/src/backends/native-project-mapping-command.ts index 995d5b476..2b3f7620c 100644 --- a/src/backends/native-project-mapping-command.ts +++ b/src/backends/native-project-mapping-command.ts @@ -8,7 +8,7 @@ import { sanitizeBranchSlug, } from "../lib/project.ts"; import { - inspectNativeProjectRunFilesystemRecovery, + inspectNativeProjectRunMapping, recoverNativeProjectRunFilesystem, } from "./native-project-run.ts"; import type { NativeRuntimeSelection } from "./native-runtime-client.ts"; @@ -108,7 +108,7 @@ export async function runNativeProjectMappingCommand(opts: { }; const data = opts.selection.action === "inspect" - ? await inspectNativeProjectRunFilesystemRecovery({ + ? await inspectNativeProjectRunMapping({ scope, runtime: opts.runtime, }) @@ -120,6 +120,12 @@ export async function runNativeProjectMappingCommand(opts: { }); if (opts.json) { emitCliResult({ result: okResult({ data }) }); + } else if ("state" in data) { + process.stdout.write( + `Native run mapping is current: branch=${selected.branch ?? "base"}, run=${data.run.run}\n` + + `graph=${data.graph ? `${data.graph.phase}, ownership ${data.graph.ownershipMatches ? "matches" : "differs"}` : "unavailable"}\n` + + "No repair is needed. Graph recovery, retained-data readback and application readiness require separate verification.\n" + ); } else { process.stdout.write( `Native run mapping ${opts.selection.action === "repair" ? "repaired" : "inspected"}: branch=${selected.branch ?? "base"}, run=${data.run.run}\n` + diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index a8404c8c8..cc68860a6 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -12,6 +12,11 @@ import { nativeImagePolicyArgs, } from "./native-image-policy.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; +import { + admitNativeProjectBuilds, + nativeBuildsEnabled, + nativeServiceActive, +} from "./native-project-builds.ts"; import { compileNativeHostDependencySelection, discoverNativeHostDependency, @@ -300,6 +305,7 @@ async function stoppedRestartMode(opts: { async function pinRestartImages(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; + readonly profiles: readonly string[]; readonly specs: Record>; readonly retainedImages: ReadonlyMap; readonly amd64Emulation?: Readonly>; @@ -309,8 +315,21 @@ async function pinRestartImages(opts: { const retainedImage = opts.retainedImages.get(name); if (retainedImage) { spec.image = retainedImage; + // Startup pins a built image the same way: the content ID replaces the build + // declaration, so the restart review matches the saved image-only plan. + Reflect.deleteProperty(spec, "build"); continue; } + if (spec.image === undefined && spec.build !== undefined) { + // Outside the selected profiles a build service is never built or pinned + // (as at startup), so it keeps its build declaration. + if (!nativeServiceActive(spec, opts.profiles)) { + continue; + } + throw new ClosedCliError( + `Native restart reuses built images and never builds; no running or retained image was found for ${name}. Run hack up to build it; nothing was stopped.` + ); + } const image = String(spec.image); if (IMAGE.test(image)) { continue; @@ -470,9 +489,25 @@ export async function preflightNativeRestart(opts: { }) : undefined; input = metadata?.input ?? input; + // Restart never builds: an admitted build service must resolve to the image the + // current or retained graph already runs (see pinRestartImages). const specs = prepareNativeProjectServices( input, - dependencyFile !== undefined + dependencyFile !== undefined, + new Set( + admitNativeProjectBuilds({ + services: Object.fromEntries( + Object.entries( + JSON.parse(input.normalizedComposeJson).services ?? {} + ).filter((entry): entry is [string, Record] => + isRecord(entry[1]) + ) + ), + projectRoot: opts.scope.projectRoot, + composeFile: opts.composeFile, + enabled: nativeBuildsEnabled(), + }).keys() + ) ); const hostAliases = nativeServiceHostAliases({ services: specs, @@ -531,6 +566,7 @@ export async function preflightNativeRestart(opts: { await pinRestartImages({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, + profiles: selected.profiles ?? [], specs, retainedImages, amd64Emulation: input.amd64Emulation, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index fc1dade65..480eae089 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -1074,6 +1074,99 @@ export async function inspectNativeProjectRunFilesystemRecovery(opts: { return (await recoverySelection(opts)).inspection; } +export type NativeProjectRunCurrentInspection = { + readonly schema: typeof CURRENT_SCHEMA; + readonly state: "current"; + readonly mappingSha256: string; + readonly run: NativeProjectRun; + /** Absent when the native graph cannot be read, for example while the pool is stopped. */ + readonly graph: { + readonly phase: string; + readonly ownershipMatches: boolean; + } | null; + readonly repairRequired: false; +}; + +const CURRENT_SCHEMA = "hack.native-project-run-mapping-inspection/v1"; +const GRAPH_PHASE = /^[a-z][a-z-]{0,63}$/; + +async function currentGraph(opts: { + readonly projectRoot: string; + readonly run: NativeProjectRun; + readonly runtime: NativeRuntimeSelection; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise { + let observed: unknown; + try { + observed = await inspectNativeProjectGraph({ + runtime: opts.runtime, + projectRoot: opts.projectRoot, + run: opts.run.run, + invoke: opts.invoke ?? invokeNativeRuntime, + }); + } catch { + return null; + } + const receipt = isRecord(observed) ? observed.receipt : undefined; + if ( + !( + isRecord(receipt) && + typeof receipt.phase === "string" && + GRAPH_PHASE.test(receipt.phase) + ) + ) { + return null; + } + return { + phase: receipt.phase, + ownershipMatches: + isRecord(observed) && + observed.journal_incomplete === false && + receipt.run === opts.run.run && + receipt.owner === opts.run.owner && + receipt.namespace === opts.run.namespace && + receipt.plan_id === opts.run.planId, + }; +} + +/** + * Read-only Doctor inspection of the saved run mapping. A mapping that still + * matches this checkout is reported with its native graph phase and whether the + * graph's ownership matches it; nothing needs repair. Only a mapping whose scope + * no longer matches falls through to the explicit legacy device-rebind + * inspection, which refuses every other difference. + */ +export async function inspectNativeProjectRunMapping(opts: { + readonly scope: NativeProjectRunScope; + readonly runtime: NativeRuntimeSelection; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise< + NativeProjectRunCurrentInspection | NativeProjectRunFilesystemInspection +> { + const p = await paths(opts.scope, false); + await recoveryDirectories(p); + const { bytes, value } = await readRecoveryFile(p.file); + let run: NativeProjectRun; + try { + run = record(value, p.identity); + } catch { + return await inspectNativeProjectRunFilesystemRecovery(opts); + } + return { + schema: CURRENT_SCHEMA, + state: "current", + mappingSha256: createHash("sha256").update(bytes).digest("hex"), + run, + graph: await currentGraph({ + projectRoot: p.identity.projectRoot, + run, + runtime: opts.runtime, + invoke: opts.invoke, + }), + repairRequired: false, + }; +} + /** Explicitly rebind only three mapping device numbers; graph history is untouched. */ export async function recoverNativeProjectRunFilesystem(opts: { readonly scope: NativeProjectRunScope; diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index e4bafdc11..bd9f07ef3 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -30,6 +30,16 @@ import { preparedBaseArguments, } from "./native-prepared-base.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; +import { + admitNativeProjectBuilds, + buildNativeProjectImages, + invokeNativeBuild, + type NativeBuildRequest, + nativeBuildsEnabled, + nativeRebuildRequested, + refuseEmulatedBuilds, + refuseManagedSecretBuildArguments, +} from "./native-project-builds.ts"; import { nativeSharedSourceFlags, nativeStartCacheSource, @@ -323,6 +333,7 @@ type Dependencies = { recoverHttps: typeof recoverNativeHttpsLease; cleanupTemporary: (directory: string) => Promise; invoke: typeof invokeNativeRuntime; + build: typeof invokeNativeBuild; load: typeof loadNativeProjectRun; loadRestart: typeof loadNativeRestartIntent; save: typeof saveNativeProjectRun; @@ -353,6 +364,7 @@ const DEFAULTS: Dependencies = { cleanupTemporary: (directory) => rm(directory, { recursive: true, force: true }), invoke: invokeNativeRuntime, + build: invokeNativeBuild, load: loadNativeProjectRun, loadRestart: loadNativeRestartIntent, save: saveNativeProjectRun, @@ -406,9 +418,66 @@ function requireActiveStartup(signal: AbortSignal | undefined): void { throw canceled(); } } +function admitStartBuilds( + input: NativeProjectInput, + opts: { + readonly scope: { readonly projectRoot: string }; + readonly composeFile: string; + } +): ReadonlyMap { + const builds = admitNativeProjectBuilds({ + services: composeServices(input), + projectRoot: opts.scope.projectRoot, + composeFile: opts.composeFile, + enabled: nativeBuildsEnabled(), + }); + refuseManagedSecretBuildArguments({ + builds, + managedSecretValues: input.managedSecretValues ?? [], + }); + // Native builds produce linux/arm64 images; amd64 emulation cannot apply to them. + refuseEmulatedBuilds({ builds, amd64Emulation: input.amd64Emulation }); + return builds; +} + +/** A service names an image, or is an admitted build that names none yet. */ +function imageOrAdmittedBuild( + name: string, + value: Readonly>, + builds: ReadonlySet +): boolean { + if (value.build === undefined) { + return typeof value.image === "string"; + } + return builds.has(name) && value.image === undefined; +} + +/** The normalized Compose services, or the closed configuration refusal. */ +function composeServices( + input: NativeProjectInput +): Record> { + const compose: unknown = JSON.parse(input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw refused(); + } + const services: Record> = {}; + for (const [name, value] of Object.entries(compose.services)) { + if (!isRecord(value)) { + throw refused(); + } + services[name] = value; + } + return services; +} + +/** + * `builds` names the services whose Compose `build` was admitted + * (`admitNativeProjectBuilds`); only those may omit `image` until they are built. + */ export function prepareNativeProjectServices( input: NativeProjectInput, - hasHostDependencies = false + hasHostDependencies = false, + builds: ReadonlySet = new Set() ): Record> { const compose: unknown = JSON.parse(input.normalizedComposeJson); if (!(isRecord(compose) && isRecord(compose.services))) { @@ -417,9 +486,7 @@ export function prepareNativeProjectServices( const result: Record> = {}; for (const [name, value] of Object.entries(compose.services)) { if ( - !isRecord(value) || - typeof value.image !== "string" || - value.build !== undefined || + !(isRecord(value) && imageOrAdmittedBuild(name, value, builds)) || (value.extra_hosts !== undefined && !hasHostDependencies) || value.logging !== undefined || // Restart policies are admitted (or refused with a fixed reason) by the @@ -456,6 +523,10 @@ async function pinNativeProjectImages(opts: { spec.image = retainedImage; continue; } + // A build service left unbuilt here is outside the selected profiles. + if (spec.image === undefined && spec.build !== undefined) { + continue; + } const image = String(spec.image); if (IMAGE.test(image)) { continue; @@ -1552,9 +1623,13 @@ export async function startNativeProject(opts: { phase: "before-runtime", }) ).input; + // Pure build admission before hooks, VM or provider start; refuses when disabled. + const builds = admitStartBuilds(input, opts); + const buildServices = new Set(builds.keys()); let specs = prepareNativeProjectServices( input, - selectedDependencyFile !== undefined + selectedDependencyFile !== undefined, + buildServices ); // Refuse malformed or unacquirable references before hooks, VM or provider start. await admitNativeProjectImages({ @@ -1614,7 +1689,8 @@ export async function startNativeProject(opts: { input = credentials.input; specs = prepareNativeProjectServices( input, - selectedDependencyFile !== undefined + selectedDependencyFile !== undefined, + buildServices ); } const dependencyDiscoveryDeadline = performance.now() + 60_000; @@ -1732,7 +1808,8 @@ export async function startNativeProject(opts: { requireActiveStartup(controller.signal); specs = prepareNativeProjectServices( input, - selectedDependencyFile !== undefined + selectedDependencyFile !== undefined, + buildServices ); prepareNativeDependencyServices({ dependencies: hostDependencies, @@ -1747,6 +1824,19 @@ export async function startNativeProject(opts: { invoke: deps.invoke, }); progress({ phase: "images" }); + await buildNativeProjectImages({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + branch: opts.scope.branch, + profiles, + builds, + specs, + retainedImages, + restore: Boolean(restore), + rebuild: nativeRebuildRequested(), + signal: controller.signal, + invoke: deps.build, + }); await pinNativeProjectImages({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 82dfaed7f..1f0f1bfb2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { checkLegacyProjectAgentArtifacts, checkLegacyUserAgentArtifacts, } from "../agents/legacy-artifacts.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { runNativeProjectCleanupCommand } from "../backends/native-project-cleanup-command.ts"; import { parseNativeRunMappingRecoveryOptions, @@ -375,7 +376,7 @@ const doctorOptions = [ long: "--native-run-mapping", valueHint: "inspect|repair", description: - "Inspect or explicitly repair a native run mapping after filesystem device renumbering", + "Inspect a native run mapping, or explicitly repair it after filesystem device renumbering", } as const), defineOption({ name: "expectSelection", @@ -3070,11 +3071,15 @@ async function checkComposeBackendFeatures(opts: { requestedEnvName: undefined, }) : undefined; + // The same build classification native up applies under HACK_NATIVE_BUILDS. + const buildsEnabled = nativeBuildsEnabled(); const reading = await readComposeBackendReading(ctx.composeFile, { adaptationCandidates, + buildsEnabled, }); return composeBackendCheckResult({ backend: opts.backend, + buildsEnabled, features: reading.features, basicBuild: reading.basicBuild, }); diff --git a/src/commands/project.ts b/src/commands/project.ts index a42a7cf56..fb03ce218 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -43,6 +43,7 @@ import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; import { createNativeDependencyOwnerResolver } from "../backends/native-dependency-owners.ts"; import { parseNativeGcloudSelection } from "../backends/native-gcloud-environment.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { nativeDownEnvironment, nativeProjectDown, @@ -5921,6 +5922,8 @@ async function handleNativeUp({ // The selected adaptation applies first: a network it isolates is not // external here, exactly as native start normalizes it. Without --env every // committed per-env adaptation is a candidate (see nativeAdaptationCandidates). + // With native builds enabled, build fields native builds implement pass + // here and native build admission reviews them, secret-valued args included. const requestedEnvName = resolveRequestedEnvName({ envOption: args.options.env, }); @@ -5935,6 +5938,7 @@ async function handleNativeUp({ requestedEnvName, }), restart, + buildsEnabled: nativeBuildsEnabled(), }); const branch = await resolveEffectiveBranchForCommand({ project, diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index 4fcf40568..ce331fd71 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -2,6 +2,7 @@ import { BASIC_BUILD_NEXT_STEP, COMPOSE_BACKEND_SELECTION, type ComposeBackendFeature, + type ComposeBackendFeatureOptions, describeComposeBackendFeatures, ISOLATED_NETWORKS_SELECTION, } from "./native-compose-backend-features.ts"; @@ -30,17 +31,20 @@ export type ComposeBackendReport = { * On the native backend a blocking feature is a warning, because native `up` * refuses the project; on the Compose backend it is informational, because * the project already runs where those features are supported. A basic build - * is not a Compose-backend feature, but native image-project `up` still - * refuses it (HACK-1907), so on the native backend it is a warning too. + * is not a Compose-backend feature, but native image-project `up` refuses it + * while `HACK_NATIVE_BUILDS=1` is not set (HACK-1907), so on the native backend + * it is a warning too; with native builds enabled the reading reports none. * Each warning names the next step: the Compose backend selection, the * documented `isolatedNetworks` adaptation for an external network, or the * authored native format for a build. */ -export function composeBackendCheckResult(opts: { - readonly backend: "native" | "compose"; - readonly features: readonly ComposeBackendFeature[]; - readonly basicBuild?: boolean; -}): { +export function composeBackendCheckResult( + opts: { + readonly backend: "native" | "compose"; + readonly features: readonly ComposeBackendFeature[]; + readonly basicBuild?: boolean; + } & ComposeBackendFeatureOptions +): { readonly name: string; readonly status: "ok" | "warn"; readonly message: string; @@ -62,7 +66,7 @@ export function composeBackendCheckResult(opts: { return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build with its general configuration refusal. ${BASIC_BUILD_NEXT_STEP}, or ${COMPOSE_BACKEND_SELECTION} to run it on the Compose backend.`, + message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${BASIC_BUILD_NEXT_STEP}, or ${COMPOSE_BACKEND_SELECTION} to run it on the Compose backend.`, composeBackend, }; } @@ -70,7 +74,7 @@ export function composeBackendCheckResult(opts: { return { name: COMPOSE_BACKEND_CHECK, status: "ok", - message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it: ${BASIC_BUILD_NEXT_STEP}.`, + message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it while HACK_NATIVE_BUILDS is not set: ${BASIC_BUILD_NEXT_STEP}.`, composeBackend, }; } @@ -82,7 +86,7 @@ export function composeBackendCheckResult(opts: { composeBackend, }; } - const uses = `This project uses ${describeComposeBackendFeatures(opts.features)}`; + const uses = `This project uses ${describeComposeBackendFeatures(opts.features, opts)}`; if (opts.backend === "native") { return { name: COMPOSE_BACKEND_CHECK, diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 9355ce9ed..6856e6f4e 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -1,5 +1,6 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; +import { NATIVE_BUILD_FIELDS } from "../backends/native-project-builds.ts"; import { type NativePlatformMode, selectNativePlatformModes, @@ -82,6 +83,25 @@ const FEATURE_PHRASES: Readonly> = { foreign_platform: "a service platform other than linux/arm64", }; +/** "a, b, c and d" for the build fields native builds implement. */ +function nativeBuildFieldList(): string { + return `${NATIVE_BUILD_FIELDS.slice(0, -1).join(", ")} and ${NATIVE_BUILD_FIELDS.at(-1)}`; +} + +/** The `unsupported_build` phrase while `HACK_NATIVE_BUILDS=1` is set. */ +const NATIVE_BUILDS_PHRASE = `a Compose build field other than ${nativeBuildFieldList()}, or image together with build`; + +/** Options shared by the classifier, its readers and its messages. */ +export type ComposeBackendFeatureOptions = { + /** + * Whether native builds are enabled (`HACK_NATIVE_BUILDS=1`). Then a build + * is a Compose-backend feature only when it declares a field native builds + * do not implement, or `image` together with `build`; native build + * admission reviews the rest, including `args` and the managed-secret check. + */ + readonly buildsEnabled?: boolean; +}; + function labelKeys(labels: unknown): readonly string[] { if (isRecord(labels)) { return Object.keys(labels); @@ -118,30 +138,46 @@ function isExternalNetwork(network: unknown): boolean { } /** - * A build is inside the supported path only when the import preview's basic - * build mapper accepts it and the service names no image, exactly as - * `hack config import --dry-run` reports `invalid_or_unsupported_build` and - * `image_build_exclusive`. + * Without native builds, a build is inside the supported path only when the + * import preview's basic build mapper accepts it and the service names no + * image, exactly as `hack config import --dry-run` reports + * `invalid_or_unsupported_build` and `image_build_exclusive`. + * + * With native builds, the gate admits exactly the fields native builds + * implement ({@link NATIVE_BUILD_FIELDS}). Values in those fields, such as + * paths, interpolation and build args, are left to native build admission, + * which refuses with the service name and never suggests the Compose backend: + * a build arg that carries a secret would be recorded in the image history + * there too. */ -function isUnsupportedBuild(service: Record): boolean { +function isUnsupportedBuild( + service: Record, + buildsEnabled: boolean +): boolean { if (!Object.hasOwn(service, "build")) { return false; } - return ( - Object.hasOwn(service, "image") || - mapLegacyComposeBuild(service.build) === undefined - ); + if (Object.hasOwn(service, "image")) { + return true; + } + const build = service.build; + if (buildsEnabled) { + const fields: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); + return ( + isRecord(build) && Object.keys(build).some((key) => !fields.has(key)) + ); + } + return mapLegacyComposeBuild(build) === undefined; } /** * A basic build is one the import preview's build mapper accepts for a service * that names no image. It does not keep the project on the Compose backend, - * but native image-project `up` still refuses it with its general - * configuration refusal (`NATIVE_CONFIGURATION_REFUSAL`), because native builds - * need the authored native format. + * but without `HACK_NATIVE_BUILDS=1` native image-project `up` refuses it + * (`NATIVE_BUILDS_DISABLED_REFUSAL`). */ function isBasicBuild(service: Record): boolean { - return Object.hasOwn(service, "build") && !isUnsupportedBuild(service); + return Object.hasOwn(service, "build") && !isUnsupportedBuild(service, false); } /** @@ -207,7 +243,7 @@ export function findComposeBackendFeatures( * emulation; see {@link readAdaptedEmulatedServices}. */ readonly emulatedServices?: ReadonlySet; - } + } & ComposeBackendFeatureOptions ): ComposeBackendFeature[] { if (!isRecord(compose)) { return []; @@ -238,7 +274,8 @@ export function findComposeBackendFeatures( ) { found.add("reserved_label"); } - if (services.some(isUnsupportedBuild)) { + const buildsEnabled = opts?.buildsEnabled === true; + if (services.some((service) => isUnsupportedBuild(service, buildsEnabled))) { found.add("unsupported_build"); } const emulated = opts?.emulatedServices ?? new Set(); @@ -367,12 +404,18 @@ export async function readComposeBackendReading( readonly adaptationFile?: string; /** See {@link readComposeBackendFeatures}; replaces `adaptationFile`. */ readonly adaptationCandidates?: readonly (string | undefined)[]; - } + } & ComposeBackendFeatureOptions ): Promise { const compose = await readNativeComposeDocument(composeFile); + const buildsEnabled = opts?.buildsEnabled === true; return { - features: await classifyAdapted(compose, adaptationCandidates(opts)), - basicBuild: hasBasicComposeBuild(compose), + features: await classifyAdapted( + compose, + adaptationCandidates(opts), + buildsEnabled + ), + // With native builds enabled, native `up` builds a basic build itself. + basicBuild: !buildsEnabled && hasBasicComposeBuild(compose), }; } @@ -404,9 +447,10 @@ function intersect( */ async function classifyAdapted( compose: unknown, - candidates: readonly (string | undefined)[] + candidates: readonly (string | undefined)[], + buildsEnabled: boolean ): Promise { - const features = findComposeBackendFeatures(compose); + const features = findComposeBackendFeatures(compose, { buildsEnabled }); const network = features.includes("external_network"); const platform = features.includes("foreign_platform"); if ( @@ -442,6 +486,7 @@ async function classifyAdapted( return findComposeBackendFeatures(compose, { isolatedNetworks: isolated, emulatedServices: emulated, + buildsEnabled, }); } @@ -491,10 +536,14 @@ export async function readComposeBackendFeatures( * selected adaptation would leave external. Replaces `adaptationFile`. */ readonly adaptationCandidates?: readonly (string | undefined)[]; - } + } & ComposeBackendFeatureOptions ): Promise { const compose = await readNativeComposeDocument(composeFile); - return await classifyAdapted(compose, adaptationCandidates(opts)); + return await classifyAdapted( + compose, + adaptationCandidates(opts), + opts?.buildsEnabled === true + ); } /** @@ -527,9 +576,14 @@ export async function readNativePlatformModes(opts: { /** Value-free phrase list, such as "a and b" or "a, b and c". */ export function describeComposeBackendFeatures( - features: readonly ComposeBackendFeature[] + features: readonly ComposeBackendFeature[], + opts?: ComposeBackendFeatureOptions ): string { - const phrases = features.map((feature) => FEATURE_PHRASES[feature]); + const phrases = features.map((feature) => + feature === "unsupported_build" && opts?.buildsEnabled === true + ? NATIVE_BUILDS_PHRASE + : FEATURE_PHRASES[feature] + ); if (phrases.length <= 1) { return phrases[0] ?? ""; } @@ -547,23 +601,25 @@ export const COMPOSE_BACKEND_SELECTION = export const ISOLATED_NETWORKS_SELECTION = "An external network can stay on native instead: list it by name in `isolatedNetworks` of the `HACK_NATIVE_ADAPTATION` selection, and native start replaces it with an owned isolated network that does not join the external one"; -/** The next step for a basic build, which native image-project `up` refuses. */ +/** The next step for a basic build, which native image-project `up` refuses without native builds. */ export const BASIC_BUILD_NEXT_STEP = - "Native builds need the authored native format: preview the conversion with `hack config import --dry-run --path `"; + "Set HACK_NATIVE_BUILDS=1 to build it in the native runtime, or preview the authored native conversion with `hack config import --dry-run --path `"; /** * The closed refusal for a project that needs the Compose backend: stable code * `E_NATIVE_PROJECT_UNSUPPORTED`, one line, and a `detail` that lists only the * closed feature names. */ -export function composeBackendRefusal(opts: { - readonly features: readonly ComposeBackendFeature[]; - readonly restart: boolean; -}): HackCliError { +export function composeBackendRefusal( + opts: { + readonly features: readonly ComposeBackendFeature[]; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, + message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, detail: { backend: "compose", features: [...opts.features] }, }); } @@ -573,19 +629,22 @@ export function composeBackendRefusal(opts: { * needs the Compose backend. Call it after project selection and before any * runtime request, storage preparation, lifecycle hook or stop. */ -export async function refuseNativeComposeBackendFeatures(opts: { - readonly composeFile: string; - /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ - readonly adaptationFile?: string; - /** Every adaptation native start could apply; see {@link readComposeBackendFeatures}. */ - readonly adaptationCandidates?: readonly (string | undefined)[]; - readonly restart: boolean; -}): Promise { +export async function refuseNativeComposeBackendFeatures( + opts: { + readonly composeFile: string; + /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ + readonly adaptationFile?: string; + /** Every adaptation native start could apply; see {@link readComposeBackendFeatures}. */ + readonly adaptationCandidates?: readonly (string | undefined)[]; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): Promise { const features = await readComposeBackendFeatures(opts.composeFile, { adaptationFile: opts.adaptationFile, adaptationCandidates: opts.adaptationCandidates, + buildsEnabled: opts.buildsEnabled, }); if (features.length > 0) { - throw composeBackendRefusal({ features, restart: opts.restart }); + throw composeBackendRefusal({ ...opts, features }); } } diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 88d92499c..dcfb6fbe4 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1248,3 +1248,28 @@ restricted to steps from states that still satisfy the invariant, and are listed The model does not cover provider behavior: that the pinned provider mounts an updated record at the next boot, virtio-fs device limits, or live multi-project graphs on a host VM. Cleanup is abstracted to one run's own resources. + +## Native image build retention + +`native-build-retention/BuildRetention.tla` follows `provider::image_build` and its +`reclaim` module for one build key in one namespace, with three content IDs and two +storage incarnations. The positive configuration exhausts **6,181 distinct states**. + +| Model operation | Source boundary | +| --- | --- | +| `BeginBuild` | Under the provider lease: shared build fence, per-key lock, `building` receipt. | +| `RunBuild` / `ForeignImage` | Unleased BuildKit run writing an image with this pool's labels; a forged or foreign image may appear at any time. | +| `CompleteBuild` | Under the lease: the reported ID is adopted only with this incarnation's labels and the admitted incarnation; otherwise `failed`. | +| `Cancel` / `CrashHost` / `RecordDeadAttempt` | Cancellation and host death release the fence; a `building` receipt under the key lock is recorded `indeterminate`. | +| `Down` / `RecreateStorage` | `runtime down` refuses while the fence is held; recreated storage is a new incarnation with an empty engine. | +| `StartGraph` / `RemoveContainers` / `Restore` | Graphs use only a verified image of this incarnation and keep a reference to it; data-retaining cleanup removes containers but not the reference; restore never builds. | +| `Reclaim` / `PruneCache` | Owned, non-current, unreferenced, unused images only, with no build admitted; cache pruning never touches images. | + +Three controls each remove one guard and must fail at the named action: +`negative` (reclaim ignores retained references) fails `NoReclaimReferenced` at +`Reclaim`; `foreign-adoption` (adoption without the label/incarnation check) fails +`NoForeignAdoption` at `CompleteBuild`; `down-under-build` (down ignores the fence) +fails `DownNotUnderBuild` at `Down`. The model does not cover a build client that +outlives a killed `hack-native` process (the implementation keeps the fence and key +lock held by the inherited descriptors until that client exits), real BuildKit or +engine behavior, receipt durability, or more than one key and namespace. diff --git a/tests/models/tla/native-build-retention/BuildRetention.tla b/tests/models/tla/native-build-retention/BuildRetention.tla new file mode 100644 index 000000000..f9a459253 --- /dev/null +++ b/tests/models/tla/native-build-retention/BuildRetention.tla @@ -0,0 +1,115 @@ +------------------------- MODULE BuildRetention ------------------------- +(***************************************************************************) +(* Native image build retention: `provider::image_build` and its *) +(* `reclaim` module. One build key, one namespace, a bounded supply of *) +(* content IDs. A build is admitted under the provider lease (fence and *) +(* `building` receipt), runs unleased, and is adopted under the lease only *) +(* when the reported image carries this incarnation's labels and the *) +(* incarnation is the one admitted. `runtime down` stops the engine only *) +(* when no build holds the shared fence. Reclamation removes only owned, *) +(* non-current images that no retained graph state references; the engine *) +(* itself refuses images a container uses. Unsafe* constants remove one *) +(* guard each for the negative controls. *) +(***************************************************************************) +EXTENDS Naturals, FiniteSets +CONSTANTS UnsafeReclaim, UnsafeAdopt, UnsafeDown +VARIABLES incarnation, engineUp, engine, owner, next, receipt, build, + fence, refs, used, removed +vars == <> +Ids == 1..3 +Phases == {"none", "building", "verified", "failed", "canceled", + "indeterminate"} + +Init == /\ incarnation = 1 /\ engineUp = TRUE /\ engine = {} + /\ owner = [i \in Ids |-> 0] /\ next = 1 + /\ receipt = [phase |-> "none", image |-> 0, inc |-> 0] + /\ build = "idle" /\ fence = FALSE + /\ refs = {} /\ used = {} /\ removed = 0 + +(* Step 1: under the lease, take the fence and record the attempt. *) +BeginBuild == /\ engineUp /\ build = "idle" + /\ build' = "running" /\ fence' = TRUE + /\ receipt' = [phase |-> "building", image |-> 0, inc |-> incarnation] + /\ UNCHANGED <> +(* Step 4, unleased: BuildKit writes a fresh image with this pool's labels. *) +RunBuild == /\ build = "running" /\ engineUp /\ next \in Ids + /\ engine' = engine \cup {next} + /\ owner' = [owner EXCEPT ![next] = incarnation] + /\ next' = next + 1 /\ build' = "done" + /\ UNCHANGED <> +(* A forged or foreign image appears in the engine (Dockerfile labels, other pool). *) +ForeignImage == /\ engineUp /\ next \in Ids + /\ engine' = engine \cup {next} /\ next' = next + 1 + /\ UNCHANGED <> +(* Step 5: the reported ID may be any present image; only an owned one under *) +(* the admitted incarnation is adopted. *) +CompleteBuild == /\ build = "done" /\ engineUp + /\ \E reported \in engine : + receipt' = IF UnsafeAdopt \/ + (owner[reported] = incarnation /\ receipt.inc = incarnation) + THEN [phase |-> "verified", image |-> reported, inc |-> receipt.inc] + ELSE [receipt EXCEPT !.phase = "failed"] + /\ build' = "idle" /\ fence' = FALSE + /\ UNCHANGED <> +Cancel == /\ build \in {"running", "done"} + /\ build' = "idle" /\ fence' = FALSE + /\ receipt' = [receipt EXCEPT !.phase = "canceled"] + /\ UNCHANGED <> +(* hack-native dies: the flock-backed fence and key lock are released; the *) +(* receipt stays `building` and the next attempt records it indeterminate. *) +CrashHost == /\ build # "idle" /\ build' = "idle" /\ fence' = FALSE + /\ UNCHANGED <> +RecordDeadAttempt == /\ build = "idle" /\ receipt.phase = "building" + /\ receipt' = [receipt EXCEPT !.phase = "indeterminate"] + /\ UNCHANGED <> +Down == /\ engineUp /\ (UnsafeDown \/ ~fence) + /\ engineUp' = FALSE + /\ UNCHANGED <> +Up == /\ ~engineUp /\ engineUp' = TRUE + /\ UNCHANGED <> +(* The storage disk is recreated: a new incarnation with an empty engine. *) +RecreateStorage == /\ ~engineUp /\ build = "idle" /\ incarnation = 1 + /\ incarnation' = 2 /\ engine' = {} /\ used' = {} + /\ UNCHANGED <> +(* A graph starts from the verified image of this incarnation and retains a *) +(* reference to it; its containers use it until removed. *) +StartGraph == /\ engineUp /\ receipt.phase = "verified" + /\ receipt.inc = incarnation /\ receipt.image \in engine + /\ owner[receipt.image] = incarnation + /\ refs' = refs \cup {receipt.image} /\ used' = used \cup {receipt.image} + /\ UNCHANGED <> +(* Data-retaining cleanup removes containers but keeps the graph reference. *) +RemoveContainers == /\ used # {} /\ used' = {} + /\ UNCHANGED <> +(* Restore uses retained references only and never builds. *) +Restore == /\ engineUp /\ build = "idle" /\ refs \cap engine # {} + /\ used' = used \cup (refs \cap engine) + /\ UNCHANGED <> +Reclaim == /\ engineUp /\ ~fence + /\ \E image \in engine : + /\ owner[image] = incarnation + /\ ~(receipt.phase = "verified" /\ receipt.image = image) + /\ (UnsafeReclaim \/ image \notin refs) + /\ image \notin used + /\ engine' = engine \ {image} /\ removed' = image + /\ UNCHANGED <> +PruneCache == /\ engineUp /\ UNCHANGED vars +Next == BeginBuild \/ RunBuild \/ ForeignImage \/ CompleteBuild \/ Cancel + \/ CrashHost \/ RecordDeadAttempt \/ Down \/ Up \/ RecreateStorage + \/ StartGraph \/ RemoveContainers \/ Restore \/ Reclaim \/ PruneCache +Spec == Init /\ [][Next]_vars + +TypeOK == /\ incarnation \in {1, 2} /\ engineUp \in BOOLEAN + /\ engine \subseteq Ids /\ owner \in [Ids -> 0..2] /\ next \in 1..4 + /\ receipt.phase \in Phases /\ receipt.image \in 0..3 /\ receipt.inc \in 0..2 + /\ build \in {"idle", "running", "done"} /\ fence \in BOOLEAN + /\ refs \subseteq Ids /\ used \subseteq Ids /\ removed \in 0..3 +(* An image retained graph state references is never reclaimed. *) +NoReclaimReferenced == removed = 0 \/ removed \notin refs +(* A verified receipt names an image produced under its own incarnation. *) +NoForeignAdoption == receipt.phase = "verified" => owner[receipt.image] = receipt.inc +(* The engine never stops under a build its operation still controls. *) +DownNotUnderBuild == build = "running" => engineUp +Safe == NoReclaimReferenced /\ NoForeignAdoption /\ DownNotUnderBuild +============================================================================= diff --git a/tests/models/tla/native-build-retention/down-under-build.cfg b/tests/models/tla/native-build-retention/down-under-build.cfg new file mode 100644 index 000000000..e5a514866 --- /dev/null +++ b/tests/models/tla/native-build-retention/down-under-build.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = FALSE UnsafeDown = TRUE +INVARIANT TypeOK +INVARIANT DownNotUnderBuild diff --git a/tests/models/tla/native-build-retention/foreign-adoption.cfg b/tests/models/tla/native-build-retention/foreign-adoption.cfg new file mode 100644 index 000000000..694d3bbf3 --- /dev/null +++ b/tests/models/tla/native-build-retention/foreign-adoption.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = TRUE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT NoForeignAdoption diff --git a/tests/models/tla/native-build-retention/negative.cfg b/tests/models/tla/native-build-retention/negative.cfg new file mode 100644 index 000000000..595325be5 --- /dev/null +++ b/tests/models/tla/native-build-retention/negative.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = TRUE UnsafeAdopt = FALSE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT NoReclaimReferenced diff --git a/tests/models/tla/native-build-retention/positive.cfg b/tests/models/tla/native-build-retention/positive.cfg new file mode 100644 index 000000000..28191ce8d --- /dev/null +++ b/tests/models/tla/native-build-retention/positive.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = FALSE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT Safe diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index 37adb0da9..e8e56fdf2 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -10,7 +10,10 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; +import { nativeBuildsDisabledRefusal } from "../src/backends/native-project-builds.ts"; import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-start.ts"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -325,16 +328,17 @@ test( test( "control: ordinary labels and a basic build keep today's native refusal", async () => { - for (const service of [ - { image: IMAGE, labels: { "com.example.team": "web" } }, - { build: ".." }, - ]) { + for (const [service, refusal] of [ + [ + { image: IMAGE, labels: { "com.example.team": "web" } }, + NATIVE_CONFIGURATION_REFUSAL, + ], + // A basic build stays refused unless native builds are explicitly enabled. + [{ build: ".." }, nativeBuildsDisabledRefusal("web")], + ] as const) { const f = await fixture({ services: { web: service } }); const result = await cli(f, ["up"]); - expectOneClosedLine( - result, - `E_NATIVE_PROJECT_UNSUPPORTED: ${NATIVE_CONFIGURATION_REFUSAL}` - ); + expectOneClosedLine(result, `E_NATIVE_PROJECT_UNSUPPORTED: ${refusal}`); expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); // The check passed: native up went on to its own source preflight. expect(await f.calls()).toEqual(["runtime check-project-share"]); @@ -343,6 +347,86 @@ test( SPAWN_TIMEOUT_MS ); +/** Native builds enabled, with the usual shared-source grant. */ +const BUILDS_ENV = { HACK_NATIVE_SHARED_SOURCE: "1", HACK_NATIVE_BUILDS: "1" }; + +test( + "with native builds enabled, build args pass the check and reach native start", + async () => { + // HACK-1925: args are a native builds field, so the Compose-backend check + // passes and native start goes on to its own source preflight and runtime. + const f = await fixture({ + services: { + web: { + build: { context: "../app", args: { GREETING: "synthetic" } }, + }, + }, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expect(result.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(result.output).toMatch(RUNTIME_REQUEST_LINE); + const calls = await f.calls(); + expect(calls).toEqual( + ["runtime check-project-share", "runtime up"].slice(0, calls.length) + ); + expect(calls.length).toBeGreaterThan(0); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build arg equal to a managed secret refuses by name only", + async () => { + const secret = "synthetic-dummy-managed-value-1925"; + const f = await fixture({ + services: { + web: { build: { context: "../app", args: { GREETING: secret } } }, + }, + }); + await writeFile( + join(f.project, PROJECT_ENV_KEY_FILENAME), + "synthetic-project-key" + ); + await setProjectEnvValue({ + projectRoot: f.project, + projectDir: join(f.project, ".hack"), + envName: null, + scope: "global", + key: "FIXTURE_TOKEN", + value: secret, + secret: true, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expectOneClosedLine( + result, + "E_NATIVE_PROJECT_UNSUPPORTED: Native foreground up cannot build service web: build arg GREETING equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started." + ); + expect(result.output).not.toContain(secret); + // The Compose backend would record the value in the image history too. + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(await f.calls()).toEqual(["runtime check-project-share"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build field native builds lack still selects the Compose backend", + async () => { + await expectComposeBackendRefusal({ + compose: { + services: { + web: { build: { context: "../app", ssh: ["default"] } }, + }, + }, + env: BUILDS_ENV, + phrase: + "a Compose build field other than context, dockerfile, target and args, or image together with build", + }); + }, + SPAWN_TIMEOUT_MS +); + test( "control: a reviewed isolated network replacement and arm64 platform pass the check", async () => { diff --git a/tests/native-compose-backend-features.test.ts b/tests/native-compose-backend-features.test.ts index 04c437a57..34fd35907 100644 --- a/tests/native-compose-backend-features.test.ts +++ b/tests/native-compose-backend-features.test.ts @@ -167,6 +167,64 @@ test("builds follow the import preview's basic build path", () => { } }); +test("with native builds enabled, exactly the fields native builds implement pass", () => { + const enabled = { buildsEnabled: true } as const; + // HACK-1925: args and the other implemented fields reach native build admission, + // which reviews values (paths, interpolation, managed secrets) itself. + for (const build of [ + "..", + "/absolute/context", + { context: "../app", args: { GREETING: "synthetic" } }, + { context: "..", args: ["A=1"], dockerfile: "Dockerfile", target: "dev" }, + { context: "../../outside" }, + { args: { A: "${HOST_VALUE}" } }, + {}, + ]) { + expect(findComposeBackendFeatures(services({ build }), enabled)).toEqual( + [] + ); + } + for (const service of [ + { build: { context: "..", platforms: ["linux/amd64"] } }, + { build: { context: "..", ssh: ["default"] } }, + { build: { context: "..", secrets: ["token"] } }, + { build: { context: "..", args: { A: "1" }, cache_from: ["x"] } }, + { build: "..", image: IMAGE }, + ]) { + expect(findComposeBackendFeatures(services(service), enabled)).toEqual([ + "unsupported_build", + ]); + } + // Without native builds, today's basic-form classification is unchanged. + expect( + findComposeBackendFeatures( + services({ build: { context: "..", args: { A: "1" } } }) + ) + ).toEqual(["unsupported_build"]); +}); + +test("the build refusal phrase follows whether native builds are enabled", () => { + const features = ["unsupported_build"] as const; + expect(composeBackendRefusal({ features, restart: false }).message).toContain( + "does not support a Compose build outside the basic context, dockerfile and target form;" + ); + const enabled = composeBackendRefusal({ + features, + restart: false, + buildsEnabled: true, + }).message; + expect(enabled).toContain( + "does not support a Compose build field other than context, dockerfile, target and args, or image together with build;" + ); + expect( + composeBackendCheckResult({ + backend: "native", + features, + buildsEnabled: true, + }).message + ).toContain("a Compose build field other than context, dockerfile"); +}); + test("only the native linux/arm64 platform passes", () => { for (const platform of ["linux/arm64", "linux/arm64/v8"]) { expect( @@ -332,7 +390,7 @@ test("doctor warns for a basic build that native image-project up refuses", () = status: "warn", composeBackend: { features: [], nativeUpRefusals: ["basic_build"] }, }); - expect(native.message).toContain("general configuration refusal"); + expect(native.message).toContain("HACK_NATIVE_BUILDS"); expect(native.message).toContain("hack config import --dry-run"); expect(native.message).toContain("HACK_RUNTIME_BACKEND=compose"); const compose = composeBackendCheckResult({ diff --git a/tests/native-compose-generation.test.ts b/tests/native-compose-generation.test.ts index b783cde7e..b4c99d8c3 100644 --- a/tests/native-compose-generation.test.ts +++ b/tests/native-compose-generation.test.ts @@ -792,59 +792,58 @@ test("effect captures its dependent finalizer before the first asynchronous chec */ const SIGKILL_ROUTE_RETIREMENT_BUDGET_MS = 30_000; -test.each([ - "recovery", - "finite-down", -] as const)("SIGKILL after %s route retirement preserves the pending stop generation for idempotent recovery", async (mode) => { - const root = await fixture(); - const owner = await store(root); - const generation = await owner.withMutation(async (mutation) => { - const published = await publish(mutation); - await mutation.runEffect({ - generation: published, - operation: "up", - assertFresh: async () => {}, - assertOwned: async () => {}, - effect: async () => ({ - outcome: mode === "recovery" ? "uncertain" : "complete", - value: 0, - }), +test.each(["recovery", "finite-down"] as const)( + "SIGKILL after %s route retirement preserves the pending stop generation for idempotent recovery", + async (mode) => { + const root = await fixture(); + const owner = await store(root); + const generation = await owner.withMutation(async (mutation) => { + const published = await publish(mutation); + await mutation.runEffect({ + generation: published, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => ({ + outcome: mode === "recovery" ? "uncertain" : "complete", + value: 0, + }), + }); + return published; }); - return published; - }); - const claims = await routeStore(root, owner.identity); - const attempt = await claims.acquire({ - hostnames: ["crash.fixture.test"], - generationIdentity: generation.generationId, - }); - await claims.markEffectsPossible(attempt); - if (mode === "recovery") { - await claims.retain(attempt); - } else { - await claims.complete({ attempt, assertTransition: async () => {} }); - } - const generationModule = new URL( - "../src/lib/native-compose-generation.ts", - import.meta.url - ).href; - const claimsModule = new URL( - "../src/lib/native-compose-route-claims.ts", - import.meta.url - ).href; - const binding = { - engineId: "fixture-engine:1", - proxyId: "a".repeat(64), - networkId: "b".repeat(64), - }; - const claimOwner = { - composeProject: owner.identity.composeProject, - ownerToken: owner.identity.ownerToken, - }; - const child = Bun.spawn( - [ - process.execPath, - "-e", - ` + const claims = await routeStore(root, owner.identity); + const attempt = await claims.acquire({ + hostnames: ["crash.fixture.test"], + generationIdentity: generation.generationId, + }); + await claims.markEffectsPossible(attempt); + if (mode === "recovery") { + await claims.retain(attempt); + } else { + await claims.complete({ attempt, assertTransition: async () => {} }); + } + const generationModule = new URL( + "../src/lib/native-compose-generation.ts", + import.meta.url + ).href; + const claimsModule = new URL( + "../src/lib/native-compose-route-claims.ts", + import.meta.url + ).href; + const binding = { + engineId: "fixture-engine:1", + proxyId: "a".repeat(64), + networkId: "b".repeat(64), + }; + const claimOwner = { + composeProject: owner.identity.composeProject, + ownerToken: owner.identity.ownerToken, + }; + const child = Bun.spawn( + [ + process.execPath, + "-e", + ` import {openNativeComposeGenerationStore} from ${JSON.stringify(generationModule)}; import {openNativeComposeRouteClaims} from ${JSON.stringify(claimsModule)}; const owner=await openNativeComposeGenerationStore({projectRoot:${JSON.stringify(root)},instance:null,mode:"saved"}); @@ -857,51 +856,55 @@ test.each([ process.kill(process.pid,"SIGKILL"); }})); `, - ], - { stdin: "ignore", stdout: "pipe", stderr: "pipe" } - ); - children.push(child); - expect(await child.exited).toBe(137); - expect(await Bun.file(join(root, "claims-retired")).text()).toBe("retired"); - expect((await claims.reopen(attempt.reference)).phase).toBe("stopped"); - expect((await owner.loadCurrent()).pending?.generationId).toBe( - generation.generationId - ); - const foreign = await routeStore(root, { - composeProject: "foreign-crash-fixture", - ownerToken: "e".repeat(32), - }); - const replacement = await foreign.acquire({ - hostnames: ["crash.fixture.test"], - generationIdentity: "f".repeat(32), - }); - await owner.recoverInterruptedLock(); - let proofs = 0; - await owner.withMutation((mutation) => - mutation.runEffect({ - generation, - operation: "down", - recoverPending: true, - assertOwned: async () => {}, - effect: async () => ({ outcome: "complete", value: 0 }), - beforeComplete: () => - claims.recoverStopped({ - references: [attempt.reference], - assertAbsent: async ({ hostnames }) => { - expect(hostnames).toEqual([]); - proofs += 1; - }, - }), - }) - ); - expect(proofs).toBe(1); - const saved = await owner.loadCurrent(); - expect(saved.pending).toBeNull(); - expect(saved.generation?.generationId).toBe(generation.generationId); - expect(saved.stopped).toBe(true); - expect((await foreign.reopen(replacement.reference)).phase).toBe("reserved"); - await foreign.rollback(replacement); -}, SIGKILL_ROUTE_RETIREMENT_BUDGET_MS); + ], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" } + ); + children.push(child); + expect(await child.exited).toBe(137); + expect(await Bun.file(join(root, "claims-retired")).text()).toBe("retired"); + expect((await claims.reopen(attempt.reference)).phase).toBe("stopped"); + expect((await owner.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + const foreign = await routeStore(root, { + composeProject: "foreign-crash-fixture", + ownerToken: "e".repeat(32), + }); + const replacement = await foreign.acquire({ + hostnames: ["crash.fixture.test"], + generationIdentity: "f".repeat(32), + }); + await owner.recoverInterruptedLock(); + let proofs = 0; + await owner.withMutation((mutation) => + mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => ({ outcome: "complete", value: 0 }), + beforeComplete: () => + claims.recoverStopped({ + references: [attempt.reference], + assertAbsent: async ({ hostnames }) => { + expect(hostnames).toEqual([]); + proofs += 1; + }, + }), + }) + ); + expect(proofs).toBe(1); + const saved = await owner.loadCurrent(); + expect(saved.pending).toBeNull(); + expect(saved.generation?.generationId).toBe(generation.generationId); + expect(saved.stopped).toBe(true); + expect((await foreign.reopen(replacement.reference)).phase).toBe( + "reserved" + ); + await foreign.rollback(replacement); + }, + SIGKILL_ROUTE_RETIREMENT_BUDGET_MS +); test("finite host intent is private, durable before effects, and cleared after verified completion", async () => { const owner = await store(await fixture()); diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts new file mode 100644 index 000000000..5e35771b8 --- /dev/null +++ b/tests/native-project-builds.test.ts @@ -0,0 +1,552 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + admitNativeProjectBuild, + admitNativeProjectBuilds, + buildNativeProjectImages, + invokeNativeBuild, + type NativeBuildRequest, + nativeBuildsDisabledRefusal, + nativeBuildsEnabled, + nativeServiceActive, + refuseEmulatedBuilds, + refuseManagedSecretBuildArguments, +} from "../src/backends/native-project-builds.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +const IMAGE = `sha256:${"a".repeat(64)}`; +const RETAINED = `sha256:${"b".repeat(64)}`; +const KEY = "c".repeat(64); +const VALUE = "synthetic-literal-value"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function root(): Promise { + const path = await mkdtemp(join(tmpdir(), "hack-native-builds-")); + roots.push(path); + return path; +} + +function admit( + service: Record, + composeFile = "/p/.hack/compose.yaml" +) { + return admitNativeProjectBuild({ + name: "app", + service, + projectRoot: "/p", + composeFile, + }); +} + +test("admitted build forms follow Compose paths and literal arguments", () => { + expect(admit({ build: ".." })).toEqual({ + context: ".", + dockerfile: "Dockerfile", + target: null, + args: {}, + }); + expect( + admit({ + build: { + context: "../app", + dockerfile: "docker/Dockerfile.dev", + target: "base", + args: { VERSION: "1.2", RETRIES: 3 }, + }, + }) + ).toEqual({ + context: "app", + dockerfile: "docker/Dockerfile.dev", + target: "base", + args: { VERSION: "1.2", RETRIES: "3" }, + }); + expect(admit({ build: { args: ["A=1", "B=x=y"] } })?.args).toEqual({ + A: "1", + B: "x=y", + }); + expect(admit({ image: "busybox" })).toBeUndefined(); +}); + +test("an argument named __proto__ survives admission and serialization", () => { + const requested = '{"__proto__":"requested-value"}'; + for (const args of [["__proto__=requested-value"], JSON.parse(requested)]) { + const request = admit({ build: { args } }); + expect(Object.keys(request?.args ?? {})).toEqual(["__proto__"]); + expect(JSON.stringify(request?.args)).toBe(requested); + } + // Declarations that differ only in that argument stay distinct builds. + const builds = admitNativeProjectBuilds({ + services: { + plain: { build: { context: "..", args: ["A=1"] } }, + proto: { + build: { context: "..", args: ["A=1", "__proto__=requested-value"] }, + }, + }, + projectRoot: "/p", + composeFile: "/p/.hack/compose.yaml", + enabled: true, + }); + expect(JSON.stringify(builds.get("proto")?.args)).toBe( + '{"A":"1","__proto__":"requested-value"}' + ); + expect(JSON.stringify(builds.get("plain")?.args)).toBe('{"A":"1"}'); +}); + +test.each([ + [{ image: "busybox", build: "." }, "both image and build"], + [{ build: { context: ".", ssh: ["default"] } }, "only build context"], + [ + { build: { context: ".", platforms: ["linux/arm64"] } }, + "only build context", + ], + [{ build: { args: { A: "${HOST_VALUE}" } } }, "interpolation"], + [{ build: { args: { A: null } } }, "interpolation and null"], + [{ build: { args: ["INHERITED"] } }, "KEY=VALUE"], + [{ build: { args: { "bad-name": "x" } } }, "unique identifiers"], + [{ build: "$CONTEXT" }, "interpolation"], + [{ build: "../.." }, "inside the project"], + [{ build: "https://example.invalid/repo.git" }, "remote build contexts"], + [ + { build: { dockerfile: "/etc/Dockerfile" } }, + "relative to the build context", + ], + [{ build: { target: "-x" } }, "stage name"], + [{ build: 7 }, "context path or a mapping"], +])("unsupported build %j refuses before any effect", (service, text) => { + const error = (() => { + try { + admit(service); + } catch (caught: unknown) { + return caught; + } + })(); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" }); + const message = String((error as Error).message); + expect(message).toContain(text); + // HACK-1927: every admission refusal names the service it is about. + expect(message).toStartWith( + "Native foreground up cannot build service app: " + ); + expect(message).not.toContain("HOST_VALUE"); + expect(message).not.toContain("HACK_RUNTIME_BACKEND"); +}); + +test("builds refuse unless explicitly enabled, and projects without builds are unaffected", () => { + const services = { web: { build: "." }, db: { image: "postgres:17" } }; + expect(() => + admitNativeProjectBuilds({ + services, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: false, + }) + ).toThrow(nativeBuildsDisabledRefusal("web")); + expect(nativeBuildsDisabledRefusal("web")).toContain( + "cannot build service web: " + ); + expect( + admitNativeProjectBuilds({ + services: { db: { image: "postgres:17" } }, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: false, + }).size + ).toBe(0); + expect([ + ...admitNativeProjectBuilds({ + services, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: true, + }).keys(), + ]).toEqual(["web"]); + expect(nativeBuildsEnabled({ HACK_NATIVE_BUILDS: "1" })).toBe(true); + expect(nativeBuildsEnabled({ HACK_NATIVE_BUILDS: "true" })).toBe(false); +}); + +const runtime = { + binary: "/unused/hack-native", + home: "/unused/home", +} as never; +const request = ( + args: Record = { A: VALUE } +): NativeBuildRequest => ({ + context: "app", + dockerfile: "Dockerfile", + target: null, + args, +}); + +test("identical builds run once, pin every service and keep the request private", async () => { + const project = await root(); + const specs: Record> = { + one: { build: "app" }, + two: { build: "app" }, + other: { build: { context: "app", target: "dev" } }, + }; + const seen: { body: Record; mode: number; path: string }[] = + []; + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: "feature", + profiles: [], + builds: new Map([ + ["one", request()], + ["two", request()], + ["other", { ...request(), target: "dev" }], + ]), + specs, + retainedImages: new Map(), + restore: false, + rebuild: false, + signal: new AbortController().signal, + invoke: async ({ requestFile }) => { + seen.push({ + body: JSON.parse(await readFile(requestFile, "utf8")), + mode: (await stat(requestFile)).mode & 0o777, + path: requestFile, + }); + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }, + }); + expect(seen).toHaveLength(2); + expect(seen[0]?.mode).toBe(0o600); + expect(seen[0]?.body).toMatchObject({ + version: 1, + branch: "feature", + context: "app", + policy: "missing", + args: { A: VALUE }, + }); + expect(seen[1]?.body.target).toBe("dev"); + for (const { path } of seen) { + expect(await Bun.file(path).exists()).toBe(false); + } + for (const spec of Object.values(specs)) { + expect(spec).toEqual({ image: IMAGE }); + } +}); + +test("retained images win, and restore never builds a missing one", async () => { + const project = await root(); + const calls: string[] = []; + const invoke = async () => { + calls.push("build"); + return { imageId: IMAGE, buildKey: KEY, reused: true }; + }; + const base = { + runtime, + projectRoot: project, + branch: null, + profiles: [], + rebuild: false, + signal: new AbortController().signal, + invoke, + }; + const specs = { web: { build: "app" } as Record }; + await buildNativeProjectImages({ + ...base, + builds: new Map([["web", request()]]), + specs, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }); + expect(specs.web).toEqual({ image: RETAINED }); + expect(calls).toEqual([]); + + const missing = await buildNativeProjectImages({ + ...base, + builds: new Map([["worker", request()]]), + specs: { worker: { build: "app" } }, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }).catch((error: unknown) => error); + expect(String(missing)).toContain("Nothing was built"); + expect(calls).toEqual([]); + + // A retained run whose configuration changed selects no retained images and builds. + await buildNativeProjectImages({ + ...base, + builds: new Map([["worker", request()]]), + specs: { worker: { build: "app" } }, + retainedImages: new Map(), + restore: true, + }); + expect(calls).toEqual(["build"]); +}); + +test("only build services in the selected profiles are built and pinned, as with Compose up", async () => { + const project = await root(); + const built: string[] = []; + const invoke = async ({ requestFile }: { requestFile: string }) => { + built.push(JSON.parse(await readFile(requestFile, "utf8")).target ?? ""); + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }; + const declared = () => ({ + web: { build: "app" } as Record, + tools: { build: "app", profiles: ["tools"] } as Record, + }); + const builds = new Map([ + ["web", request()], + ["tools", { ...request(), target: "tools" }], + ]); + const base = { + runtime, + projectRoot: project, + branch: null, + builds, + rebuild: false, + signal: new AbortController().signal, + invoke, + }; + const specs = declared(); + await buildNativeProjectImages({ + ...base, + profiles: [], + specs, + retainedImages: new Map(), + restore: false, + }); + expect(built).toEqual([""]); + expect(specs.web).toEqual({ image: IMAGE }); + // The inactive service keeps its declaration: no image, no build, no pin. + expect(specs.tools).toEqual({ build: "app", profiles: ["tools"] }); + + // Restoring that run needs no image for the inactive service. + built.length = 0; + const restored = declared(); + await buildNativeProjectImages({ + ...base, + profiles: [], + specs: restored, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }); + expect(built).toEqual([]); + expect(restored.web).toEqual({ image: RETAINED }); + expect(restored.tools).toEqual({ build: "app", profiles: ["tools"] }); + + // Selecting the profile later builds that service then. + const selected = declared(); + await buildNativeProjectImages({ + ...base, + profiles: ["tools"], + specs: selected, + retainedImages: new Map(), + restore: false, + }); + expect(built).toEqual(["", "tools"]); + expect(selected.tools).toEqual({ image: IMAGE, profiles: ["tools"] }); +}); + +test("a build service cannot select amd64 emulation; prefer-native is accepted", () => { + const builds = new Map([["web", request()]]); + let refusal: unknown; + try { + refuseEmulatedBuilds({ builds, amd64Emulation: { web: "amd64" } }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(HackCliError); + expect((refusal as HackCliError).code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(String(refusal)).toContain("linux/arm64 only"); + expect(String(refusal)).toContain("service web"); + refuseEmulatedBuilds({ builds, amd64Emulation: { web: "prefer-native" } }); + refuseEmulatedBuilds({ builds, amd64Emulation: { other: "amd64" } }); + refuseEmulatedBuilds({ builds }); +}); + +test("service activity follows Compose profiles", () => { + expect(nativeServiceActive({}, [])).toBe(true); + expect(nativeServiceActive({ profiles: ["a"] }, [])).toBe(false); + expect(nativeServiceActive({ profiles: ["a", "b"] }, ["b"])).toBe(true); + expect(nativeServiceActive({ profiles: [] }, [])).toBe(true); + expect(nativeServiceActive({ profiles: "a" }, [])).toBe(true); +}); + +test("a build arg equal to a managed secret value refuses without showing it", () => { + const secret = "managed-secret-value-not-shown"; + const builds = new Map([ + ["web", request({ TOKEN: secret, MODE: "production" })], + ]); + let refusal: unknown; + try { + refuseManagedSecretBuildArguments({ + builds, + managedSecretValues: ["other", secret], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(HackCliError); + const error = refusal as HackCliError; + expect(error.code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + // HACK-1925: the refusal names the service and the argument, never the value, + // and does not point at the Compose backend, which would bake it into history. + expect(error.message).toContain( + "cannot build service web: build arg TOKEN equals a hack env secret value" + ); + expect(error.message).not.toContain(secret); + expect(error.message).not.toContain("HACK_RUNTIME_BACKEND"); + expect(error.message).not.toContain("Compose backend"); + // Values that are not managed secrets pass; so does any build with no secrets. + refuseManagedSecretBuildArguments({ + builds: new Map([["web", request({ MODE: "production" })]]), + managedSecretValues: [secret], + }); + refuseManagedSecretBuildArguments({ builds, managedSecretValues: [] }); +}); + +test("an explicit rebuild asks the runtime to build again", async () => { + const project = await root(); + let policy: unknown; + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: null, + profiles: [], + builds: new Map([["web", request()]]), + specs: { web: { build: "app" } }, + retainedImages: new Map(), + restore: false, + rebuild: true, + signal: new AbortController().signal, + invoke: async ({ requestFile }) => { + policy = JSON.parse(await readFile(requestFile, "utf8")).policy; + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }, + }); + expect(policy).toBe("rebuild"); +}); + +async function fakeRuntime( + script: string +): Promise<{ binary: string; home: string }> { + const directory = await root(); + const binary = join(directory, "hack-native"); + await writeFile(binary, `#!/bin/sh\n${script}\n`, { mode: 0o700 }); + await chmod(binary, 0o700); + const home = join(directory, "home"); + await mkdir(home); + return { binary, home }; +} + +test("build output streams through while the runtime's closed error is returned", async () => { + const fake = await fakeRuntime( + `printf 'step 1\\nstep 2\\n' >&2\nprintf '{"code":"native_build_failed","message":"The image build failed."}\\n' >&2\nexit 2` + ); + const written: string[] = []; + const error = await invokeNativeBuild({ + runtime: fake as never, + cwd: fake.home, + requestFile: join(fake.home, "request.json"), + write: (text) => written.push(text), + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(NativeRuntimeRequestError); + expect(error).toMatchObject({ nativeCode: "native_build_failed" }); + expect(written.join("")).toBe("step 1\nstep 2\n"); + + const ok = await fakeRuntime( + `printf 'progress\\n' >&2\nprintf '{"image_id":"${IMAGE}","build_key":"${KEY}","tag":"t","reused":false,"base_pins":{}}'` + ); + const built = await invokeNativeBuild({ + runtime: ok as never, + cwd: ok.home, + requestFile: join(ok.home, "request.json"), + write: () => undefined, + }); + expect(built).toEqual({ imageId: IMAGE, buildKey: KEY, reused: false }); +}); + +test("cancellation sends SIGTERM so the runtime can stop its build client", async () => { + const fake = await fakeRuntime( + `trap 'printf terminated > "$0.signal"; exit 143' TERM\nprintf 'started\\n' >&2\nwhile :; do sleep 0.05; done` + ); + const controller = new AbortController(); + const pending = invokeNativeBuild({ + runtime: fake as never, + cwd: fake.home, + requestFile: join(fake.home, "request.json"), + signal: controller.signal, + write: (text) => { + if (text.includes("started")) { + controller.abort(); + } + }, + }).catch((caught: unknown) => caught); + const error = await pending; + expect(String(error)).toContain("canceled during an image build"); + expect(await readFile(`${fake.binary}.signal`, "utf8")).toBe("terminated"); +}); + +test("runtime build refusals name the services and the build source policy", async () => { + const project = await root(); + const policy = + "Dockerfile line 1: build images must be public Docker Hub references."; + const fail = (nativeCode: string, message: string) => async () => { + throw new NativeRuntimeRequestError({ message, nativeCode }); + }; + const run = async (invoke: () => Promise) => + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: null, + profiles: [], + // Identical declarations build once, so one refusal covers both services. + builds: new Map([ + ["web", request()], + ["worker", request()], + ]), + specs: { web: { build: "app" }, worker: { build: "app" } }, + retainedImages: new Map(), + restore: false, + rebuild: false, + signal: new AbortController().signal, + invoke, + }).catch((caught: unknown) => caught); + + const refused = await run(fail("native_build_base_image", policy)); + expect(refused).toBeInstanceOf(NativeRuntimeRequestError); + expect(refused).toMatchObject({ nativeCode: "native_build_base_image" }); + const message = (refused as Error).message; + expect(message).toStartWith( + "Native foreground up cannot build services web and worker: the native build source policy refused it. " + ); + expect(message).toContain(policy); + expect(message).toContain("Use public Docker Hub images"); + expect(message).not.toContain(VALUE); + + // A failure that is not a policy refusal names the services and keeps its message. + const failed = await run( + fail("native_build_failed", "The image build failed.") + ); + expect(failed).toMatchObject({ nativeCode: "native_build_failed" }); + expect((failed as Error).message).toBe( + "Native foreground up could not build services web and worker: The image build failed." + ); + expect((failed as Error).message).not.toContain("source policy"); + + // An inherited property name is not a policy code. + const odd = await run(fail("constructor", "Unexpected failure.")); + expect((odd as Error).message).toBe( + "Native foreground up could not build services web and worker: Unexpected failure." + ); +}); diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 3a69ed1c6..b79e5f2ca 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -16,7 +16,9 @@ import { prepareNativeProjectInput, selectActiveNativeProjectInput, } from "../src/backends/native-project-input.ts"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; import { HackCliError } from "../src/lib/cli-result.ts"; +import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -87,6 +89,40 @@ test("modern values stay private while original contracts and exact input hash s expect(await readFile(opts.composeFile)).toEqual(before); expect(await readdir(opts.projectDir)).toEqual(names); }); +test("only values stored as secrets are reported as managed secret values", async () => { + const opts = await fixture({ + services: { web: { image: "bun:1" }, worker: { image: "bun:1" } }, + }); + await writeFile( + join(opts.projectRoot, PROJECT_ENV_KEY_FILENAME), + "synthetic-key" + ); + const set = async ( + scope: string, + key: string, + value: string, + secret: boolean + ) => + await setProjectEnvValue({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + envName: null, + scope, + key, + value, + secret, + }); + await set("global", "TOKEN", "synthetic-global-secret", true); + await set("worker", "WORKER_KEY", "synthetic-worker-secret", true); + await set("web", "MODE", "production", false); + const result = await prepareNativeProjectInput(opts); + expect([...(result.managedSecretValues ?? [])].sort()).toEqual([ + "synthetic-global-secret", + "synthetic-worker-secret", + ]); + expect(result.normalizedComposeJson).not.toContain("synthetic-"); +}); + test("fourteen services and 110 managed variables each preserve overlay selection", async () => { const services = Object.fromEntries( Array.from({ length: 14 }, (_, i) => [`service${i}`, { image: "bun:1" }]) diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index b814f3b26..ed9ef5c7b 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -1169,6 +1169,92 @@ test("stopped recovery reviews retained image IDs and resolves tags only after o } }); +test("restart pins an active build service to its retained image, drops its build declaration and leaves inactive ones unbuilt", async () => { + const listener = await listenerIntentFixture(); + const previous = process.env.HACK_NATIVE_BUILDS; + process.env.HACK_NATIVE_BUILDS = "1"; + try { + let reviewed: Record | undefined; + let inactive: Record | undefined; + const builtImage = `sha256:${"7".repeat(64)}`; + await preflightNativeRestart({ + ...listener.options, + recoverStopped: true, + dependencies: { + ...listener.options.dependencies, + retainedImages: selectNativeRetainedImages, + prepare: async () => ({ + originalSha256: "1".repeat(64), + environmentFiles: [], + serviceNames: ["app"], + normalizedComposeJson: JSON.stringify({ + services: { + app: { build: { context: "..", args: { A: "1" } } }, + // Outside the run's profiles: never built, pinned or required. + tools: { build: "..", profiles: ["tools"] }, + }, + }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: "qa", + }), + invoke: async (options) => { + if (options.args[1] === "inspect") { + const observed = stoppedRetainedGraph(); + return { + ...observed, + receipt: { + ...observed.receipt, + normalized_input: { + namespace: run.namespace, + original_compose_sha256: "1".repeat(64), + normalized_compose_sha256: "3".repeat(64), + }, + resources: { + default: observed.receipt.resources.default, + data: observed.receipt.resources.data, + "container:app": { + kind: "container", + key: "app", + image: builtImage, + }, + }, + }, + }; + } + if (options.args[1] === "ensure-image") { + throw new Error("restart must not resolve or build images"); + } + return await listener.options.dependencies!.invoke!(options); + }, + review: async (review) => { + const services = JSON.parse( + review.input.normalizedComposeJson + ).services; + reviewed = services.app; + inactive = services.tools; + return await review.run({ + planId: run.planId, + namespace: run.namespace, + report: {}, + projectArgs: [], + }); + }, + }, + }); + expect(reviewed?.image).toBe(builtImage); + expect(reviewed).not.toHaveProperty("build"); + expect(inactive).toEqual({ build: "..", profiles: ["tools"] }); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "HACK_NATIVE_BUILDS"); + } else { + process.env.HACK_NATIVE_BUILDS = previous; + } + await rm(listener.directory, { recursive: true, force: true }); + } +}); + test("invalid retained image provenance refuses stopped preflight before tag resolution", async () => { const listener = await listenerIntentFixture(); try { diff --git a/tests/native-project-run-filesystem.test.ts b/tests/native-project-run-filesystem.test.ts index 742a1c949..248c2cd78 100644 --- a/tests/native-project-run-filesystem.test.ts +++ b/tests/native-project-run-filesystem.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { inspectNativeProjectRunFilesystemRecovery as inspect, + inspectNativeProjectRunMapping as inspectMapping, loadNativeProjectRun as load, type NativeProjectRunScope, recoverNativeProjectRunFilesystem as recover, @@ -329,3 +330,121 @@ test("an exact published audit supports retry after prepublication refusal", asy expect(repaired.repaired).toBe(true); expect(await load(pool.scope)).toEqual(run); }); + +test("Doctor inspects a current mapping read-only and reports its graph", async () => { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-mapping-current-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"); + const nativeHome = join(projectRoot, "candidate"); + await mkdir(projectDir); + await mkdir(nativeHome); + const scope: NativeProjectRunScope = { + projectRoot, + projectDir, + nativeHome, + branch: null, + }; + const current = { + run: "1".repeat(32), + owner: "2".repeat(32), + namespace: "3".repeat(64), + planId: "4".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + runtimeMetadata: true as const, + }; + await save({ ...scope, run: current }); + const key = createHash("sha256").update(JSON.stringify(null)).digest("hex"); + const file = join(projectDir, ".internal/native-runs", `${key}.json`); + const bytes = await readFile(file); + const calls: string[][] = []; + const graph = + (receipt: Record | Error) => + async (input: { readonly args: readonly string[] }) => { + calls.push([...input.args]); + if (receipt instanceof Error) { + throw receipt; + } + return { journal_incomplete: false, receipt }; + }; + const stopped = { + run: current.run, + owner: current.owner, + namespace: current.namespace, + plan_id: current.planId, + phase: "stopped-data-retained", + }; + + const inspected = await inspectMapping({ + scope, + runtime, + invoke: graph(stopped), + }); + expect(inspected).toEqual({ + schema: "hack.native-project-run-mapping-inspection/v1", + state: "current", + mappingSha256: createHash("sha256").update(bytes).digest("hex"), + run: current, + graph: { phase: "stopped-data-retained", ownershipMatches: true }, + repairRequired: false, + }); + // Only the read-only graph inspection runs; the mapping is unchanged. + expect(calls).toEqual([ + ["graph", "inspect", "--run-id", current.run, "--json"], + ]); + expect((await readFile(file)).equals(bytes)).toBe(true); + + // A graph owned by a different plan is reported, never repaired. + const foreign = await inspectMapping({ + scope, + runtime, + invoke: graph({ ...stopped, plan_id: "5".repeat(64) }), + }); + expect(foreign).toMatchObject({ + state: "current", + graph: { phase: "stopped-data-retained", ownershipMatches: false }, + }); + // A stopped pool leaves the graph unreadable; the mapping is still reported. + const unavailable = await inspectMapping({ + scope, + runtime, + invoke: graph(new Error("runtime stopped")), + }); + expect(unavailable).toMatchObject({ state: "current", graph: null }); + + // Repair still applies only to a device-renumbered mapping. + await expect( + recover({ + scope, + runtime, + expectSelection: "6".repeat(64), + acceptLegacyDeviceRebind: true, + invoke: graph(stopped), + }) + ).rejects.toThrow("Native project run mapping"); + // An unsafe file mode is refused, as before. + await chmod(file, 0o644); + await expect( + inspectMapping({ scope, runtime, invoke: graph(stopped) }) + ).rejects.toThrow("Native project run mapping"); + expect(await load(scope).catch(() => "refused")).toBe("refused"); + await chmod(file, 0o600); + expect(await load(scope)).toEqual(current); +}); + +test("Doctor inspection of a renumbered mapping keeps the legacy rebind selection", async () => { + const pool = await fixture(); + const invoke = authority(pool); + const inspected = await inspectMapping({ + scope: pool.scope, + runtime, + invoke, + }); + expect("state" in inspected).toBe(false); + expect(inspected).toEqual( + await inspect({ scope: pool.scope, runtime, invoke }) + ); +});