From dbcefdc01b8c4cff1a5fac96bdbf43f9b31e9faf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:38:11 -0500 Subject: [PATCH 1/8] feat(native): pin a BuildKit client and scan Dockerfile image references Groundwork for native image builds; no command builds images yet and native up still refuses build services. - provider-pins.json pins buildx 0.34.1 (darwin-arm64, Developer ID signed) by SHA-256. `runtime prepare-builder --archive ` checks the pin before creating any state, copies the file into private candidate state, rehashes the owned copy, verifies its code signature and records a tree digest. An existing tree is re-verified and reused; an interrupted preparation is retained and refused. `runtime probe` reports `builder_prepared` and refuses a changed tree. - provider/build_scan.rs is a pure scanner that reports the images a build of the selected target can pull (FROM, COPY --from, RUN --mount from=, ONBUILD triggers of internal stages). It follows the Dockerfile frontend embedded in the pinned engine: directives, escape token, continuations, heredoc bodies, global ARG substitution with literal build-argument overrides, stage names and indexes, target reachability. It refuses what it cannot resolve exactly (syntax directives, variable modifiers, quotes, uncertain platform arguments, non-arm64 platforms, stage/image name collisions, unknown instructions and flags, non-Docker-Hub references, size and count bounds) and never echoes Dockerfile text or values. Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 14 + packages/runtime-core/README.md | 3 + packages/runtime-core/provider-pins.json | 6 + packages/runtime-core/src/main.rs | 7 + .../runtime-core/src/provider/artifact.rs | 312 ++++- .../runtime-core/src/provider/build_scan.rs | 1166 +++++++++++++++++ .../src/provider/build_scan/tests.rs | 540 ++++++++ packages/runtime-core/src/provider/mod.rs | 20 + 8 files changed, 2067 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-core/src/provider/build_scan.rs create mode 100644 packages/runtime-core/src/provider/build_scan/tests.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..a220a37f0 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -443,6 +443,20 @@ 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 future native image +builds. Native `up` still refuses `build:` services and nothing runs this client yet. +Preparing it is optional: + +```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. + 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/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..9633d7895 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -477,6 +477,9 @@ 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`) prepare for native image builds; +no command builds images yet. Source binaries embed their build checkout. If a copied Cargo target directory retains a different checkout identity, use a fresh `--target-dir` for checks and 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 0ab86ffde..c52f4e015 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -94,6 +94,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] @@ -929,6 +930,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", diff --git a/packages/runtime-core/src/provider/artifact.rs b/packages/runtime-core/src/provider/artifact.rs index 771d3eddf..1be865b98 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,151 @@ 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) +} + +/// 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 +431,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_scan.rs b/packages/runtime-core/src/provider/build_scan.rs new file mode 100644 index 000000000..c7544e0d1 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -0,0 +1,1166 @@ +//! 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. Not covered here: `ONBUILD` triggers stored in a base image's own +//! configuration, which the build step must inspect on the pinned base images. +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; + +/// 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", +]; +const PLATFORM_VARIABLES: [&str; 4] = [ + "$BUILDPLATFORM", + "${BUILDPLATFORM}", + "$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" => { + parse_flags(&instruction.flags, &ADD_FLAGS, 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) +} + +/// Whether the frontend parses these arguments as a JSON string array (exec form). +fn json_form(args: &str, line: usize) -> Result { + let args = args.trim_start(); + if !args.starts_with('[') { + return Ok(false); + } + match serde_json::from_str::>(args) { + Ok(values) if values.iter().all(serde_json::Value::is_string) => Ok(true), + Ok(_) => Err(refusal( + Some(line), + "a JSON-form argument list must contain strings.", + )), + Err(_) => Ok(false), + } +} + +/// Shell words with quotes and escapes kept raw, as the heredoc detector lexes a line. +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(); + while let Some(c) = chars.next() { + match c { + 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_form(&instruction.args, line)?, + ) +} + +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.")); + } + 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.")); + } + 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 +} + +/// A global argument value: known text, or present with text the scanner cannot know. +type Env = BTreeMap>; + +/// Expand `$NAME` and `${NAME}`. Any other `$` form, quote, escape, unknown or +/// uncertain variable yields `None`. +fn expand(word: &str, env: &Env) -> 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; + } + out.push_str(env.get(&name)?.as_deref()?); + } + '\'' | '"' | '\\' | '`' => return None, + _ => out.push(c), + } + } + Some(out) +} + +/// The value of a global `ARG` default: quotes removed, `$NAME`/`${NAME}` expanded. +/// Anything else is recorded as uncertain and refuses only when a reference uses it. +fn argument_value(raw: &str, env: &Env) -> Option { + let mut out = String::new(); + let mut rest = raw; + while let Some(c) = rest.chars().next() { + match c { + '\'' => { + let body = &rest[1..]; + let end = body.find('\'')?; + out.push_str(&body[..end]); + rest = &body[end + 1..]; + } + '"' => { + let body = &rest[1..]; + let end = body.find('"')?; + out.push_str(&expand(&body[..end], env)?); + rest = &body[end + 1..]; + } + _ => { + let end = rest.find(['\'', '"']).unwrap_or(rest.len()); + out.push_str(&expand(&rest[..end], env)?); + rest = &rest[end..]; + } + } + } + Some(out) +} + +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); + } + for (name, default) in &parsed.meta_args { + if let Some(value) = args.get(name) { + env.insert(name.clone(), Some(value.clone())); + } else if let Some(default) = default { + let value = argument_value(default, &env); + env.insert(name.clone(), value); + } + } + // 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), +} + +fn platform_supported(raw: &str, env: &Env) -> bool { + PLATFORM_VARIABLES.contains(&raw) + || expand(raw, env) + .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. + let mut bases: Vec> = Vec::with_capacity(parsed.stages.len()); + for (index, stage) in parsed.stages.iter().enumerate() { + let base = expand(&stage.base, &env) + .filter(|name| !name.is_empty()) + .map(|name| { + if name == "scratch" { + Source::Scratch + } else if let Some(earlier) = parsed.stages[..index] + .iter() + .position(|s| s.name.as_deref() == Some(name.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) { + 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..f6cabc792 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -0,0 +1,540 @@ +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 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 <>() + .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/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..5a7337fee 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -3,6 +3,7 @@ pub mod admission; mod agent; mod artifact; mod bridge; +pub mod build_scan; mod config_audit; mod dependency_socket; pub use bridge::BridgeIntent; @@ -143,6 +144,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], } @@ -164,11 +167,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: [], }) @@ -185,5 +197,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; From c88d84dfa1cb99501522fa2894c9e4b3206dd875 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:33:23 -0500 Subject: [PATCH 2/8] fix(native): resolve overridden platform arguments before accepting them A global `ARG BUILDPLATFORM=...` or `ARG TARGETPLATFORM=...` default replaces the frontend's platform value, and the frontend expands `--platform` against the global arguments. The scanner accepted `--platform=$BUILDPLATFORM` by spelling alone, so such a stage could select another platform. The spelling is now accepted only while no global default overrides the argument; an overridden one must expand to linux/arm64 like any other value. A bare global ARG still keeps the frontend's value. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 32 ++++++++++++++----- .../src/provider/build_scan/tests.rs | 27 ++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index c7544e0d1..059285bff 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -56,11 +56,12 @@ const UNCERTAIN_FRONTEND_ARGS: [&str; 8] = [ "TARGETSTAGE", "SOURCE_DATE_EPOCH", ]; -const PLATFORM_VARIABLES: [&str; 4] = [ - "$BUILDPLATFORM", - "${BUILDPLATFORM}", - "$TARGETPLATFORM", - "${TARGETPLATFORM}", +/// `--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", @@ -976,8 +977,23 @@ enum Source { Image(String), } -fn platform_supported(raw: &str, env: &Env) -> bool { - PLATFORM_VARIABLES.contains(&raw) +/// 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) .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) } @@ -1082,7 +1098,7 @@ fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result Date: Thu, 5 Nov 2026 17:43:49 -0500 Subject: [PATCH 3/8] fix(native): bound argument expansion and require plain ARG names Global ARG defaults expand eagerly, so repeating `ARG A=$A$A` doubled the value each time and a few hundred bytes of Dockerfile could exhaust memory. Expansion now checks a 4 KiB cap before each append, and all held global values share a 256 KiB budget. A default past either cap is unresolved and refuses only when a reference uses it, so an oversized platform override still refuses. ARG names that are not plain identifiers are refused. The frontend would unquote, unescape or expand such a name, so a literal compare could miss an override of BUILDPLATFORM or TARGETPLATFORM. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 75 ++++++++++++++----- .../src/provider/build_scan/tests.rs | 69 +++++++++++++++++ 2 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 059285bff..6e1059041 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -37,6 +37,10 @@ pub const MAX_DOCKERFILE_BYTES: usize = 1024 * 1024; 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] = [ @@ -800,6 +804,14 @@ fn dispatch( 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)); } @@ -881,12 +893,27 @@ fn argument_words(text: &str, escape: char) -> Vec { 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>; -/// Expand `$NAME` and `${NAME}`. Any other `$` form, quote, escape, unknown or -/// uncertain variable yields `None`. -fn expand(word: &str, env: &Env) -> Option { +/// 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() { @@ -911,37 +938,39 @@ fn expand(word: &str, env: &Env) -> Option { if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { return None; } - out.push_str(env.get(&name)?.as_deref()?); + push_bounded(&mut out, env.get(&name)?.as_deref()?, cap)?; } '\'' | '"' | '\\' | '`' => return None, - _ => out.push(c), + _ => push_bounded(&mut out, c.encode_utf8(&mut [0; 4]), cap)?, } } Some(out) } -/// The value of a global `ARG` default: quotes removed, `$NAME`/`${NAME}` expanded. -/// Anything else is recorded as uncertain and refuses only when a reference uses it. -fn argument_value(raw: &str, env: &Env) -> Option { +/// 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('\'')?; - out.push_str(&body[..end]); + 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)?); + 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)?); + out.push_str(&expand(&rest[..end], env, remaining)?); rest = &rest[end..]; } } @@ -949,6 +978,11 @@ fn argument_value(raw: &str, env: &Env) -> Option { 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() @@ -957,12 +991,19 @@ fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { 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 { - if let Some(value) = args.get(name) { - env.insert(name.clone(), Some(value.clone())); + let value = if let Some(value) = args.get(name) { + Some(value.clone()) } else if let Some(default) = default { - let value = argument_value(default, &env); - env.insert(name.clone(), value); + 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. @@ -994,7 +1035,7 @@ fn platform_supported(raw: &str, env: &Env, parsed: &Parsed) -> bool { .any(|(declared, default)| declared == name && default.is_some()) }); frontend_value - || expand(raw, env) + || expand(raw, env, MAX_ARGUMENT_BYTES) .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) } @@ -1067,7 +1108,7 @@ fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result> = Vec::with_capacity(parsed.stages.len()); for (index, stage) in parsed.stages.iter().enumerate() { - let base = expand(&stage.base, &env) + let base = expand(&stage.base, &env, MAX_ARGUMENT_BYTES) .filter(|name| !name.is_empty()) .map(|name| { if name == "scratch" { diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 334e6b87d..2a895d55e 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -321,6 +321,75 @@ fn overridden_platform_arguments_must_resolve_to_arm64() { ); } +#[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 frontend_selection_directives_refuse() { for dockerfile in [ From 62f6112b378225de2ffb5082976398c1c151cb12 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:00:58 -0500 Subject: [PATCH 4/8] fix(native): refuse a build stage named scratch BuildKit links a later `FROM scratch` to an earlier stage of that name instead of the empty image. The scanner treated it as empty, so it missed that stage's image and skipped its platform check. A stage alias of `scratch` (in any case) is now refused; plain `FROM scratch` is still the empty image. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 8 ++++++++ .../src/provider/build_scan/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 6e1059041..eb6d08844 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -769,6 +769,14 @@ fn dispatch( 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.")), diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 2a895d55e..d3736e975 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -390,6 +390,25 @@ fn doubling_argument_defaults_stay_bounded() { assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); } +#[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 [ From 4f6841a47349507c901066d6041b440bc8c675ab Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:41:28 -0500 Subject: [PATCH 5/8] fix(native): keep whitespace after the heredoc operator in one word BuildKit's shell lexer keeps tabs, carriage returns and spaces after an unquoted `<<` in the same word, so `RUN true << CMD` starts a heredoc. The scanner split `<<` from the delimiter, missed the heredoc, and read its body as instructions, so a body line like `FROM scratch` could hide the real stage's image. The heredoc word lexer now absorbs that whitespace the same way. `<<- CMD` is still two words and no heredoc, as in BuildKit. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 18 ++++++++++++++- .../src/provider/build_scan/tests.rs | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index eb6d08844..6cea9b519 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -564,13 +564,29 @@ fn json_form(args: &str, line: usize) -> Result { } /// 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(); + 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)); diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index d3736e975..15ca05438 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -501,6 +501,29 @@ COPY --from=0 / / ); } +#[test] +fn whitespace_after_the_heredoc_operator_keeps_the_heredoc() { + // The frontend lexes `<< CMD` as one heredoc word, so the body is not a stage. + for dockerfile in [ + "FROM alpine:3.22\nRUN true << CMD\nFROM scratch\nCMD\n", + "FROM alpine:3.22\nRUN cat <<\t\"EOF\" > /x\nFROM scratch\nEOF\n", + "FROM alpine:3.22\nCOPY << 'FILE' /etc/x\nFROM scratch\nFILE\n", + "FROM alpine:3.22\nRUN true < Date: Thu, 5 Nov 2026 23:35:19 -0500 Subject: [PATCH 6/8] fix(native): refuse ADD sources that native builds would fetch remotely The engine fetches ADD URLs and git refs from the guest's own network namespace, so they would bypass the build network's isolation. The scanner now refuses any ADD source that BuildKit could treat as remote: after dropping quotes and escapes, a source with `://`, an scp-style `user@host:` ref, or a `$` that could expand to either. This covers shell and JSON forms, every source of a multi-source ADD, ONBUILD triggers and all flags. `--checksum` and `--keep-git-dir` apply only to remote sources and are refused outright. Local sources, local archives, heredocs and COPY are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 54 ++++++++++++++++++- .../src/provider/build_scan/tests.rs | 53 +++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 6cea9b519..abe59d7bc 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -518,7 +518,14 @@ fn instruction_uses( } } "add" => { - parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + 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)?; @@ -547,6 +554,51 @@ fn instruction_uses( 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 json_form(args, line)? { + serde_json::from_str::>(args.trim_start()).map_err(|_| { + refusal( + Some(line), + "a JSON-form argument list must contain strings.", + ) + })? + } 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(()) +} + /// Whether the frontend parses these arguments as a JSON string array (exec form). fn json_form(args: &str, line: usize) -> Result { let args = args.trim_start(); diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 15ca05438..7f6c147b8 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -549,13 +549,64 @@ fn unknown_instructions_flags_and_preamble_refuse() { // Known flags are accepted. assert_eq!( images( - "FROM alpine:3.22\nADD --link --checksum=sha256:00 --exclude=*.md https://example.com/a /a\n\ + "FROM alpine:3.22\nADD --link --chown=1:1 --chmod=644 --unpack --exclude=*.md app.tar.gz /a\n\ RUN --network=none --security=sandbox true\n" ), ["alpine:3.22"] ); } +#[test] +fn add_refuses_remote_sources() { + // The engine fetches ADD URLs and git refs outside the build network's + // isolation, so only local build context sources are allowed. + for add in [ + "ADD http://example.com/a /a", + "ADD https://example.com/a.tar.gz /a", + "ADD git://example.com/repo.git /src", + "ADD ssh://git@example.com/repo.git /src", + "ADD git@github.com:org/repo.git /src", + "ADD https://example.com/repo.git#main /src", + "ADD HTTPS://example.com/a /a", + "ADD \"https://example.com/a\" /a", + "ADD https\\://example.com/a /a", + "ADD [\"https://example.com/a\", \"/a\"]", + "ADD [\"local.txt\", \"git@github.com:org/repo.git\", \"/a/\"]", + "ADD local.txt https://example.com/b /a/", + "ADD --link --chown=1:1 https://example.com/a /a", + "ADD $URL /a", + "ADD ${SRC}/file /a", + "ADD --checksum=sha256:00 local.txt /a", + "ADD --keep-git-dir local.txt /a", + "ADD --keep-git-dir=false local.txt /a", + ] { + let dockerfile = format!("FROM alpine:3.22\n{add}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{add}"); + } + // ONBUILD triggers too. + assert_eq!( + code("FROM alpine:3.22\nONBUILD ADD https://example.com/a /a\n"), + "native_build_dockerfile" + ); + // Local sources, archives, heredocs and a destination with a variable are fine. + for add in [ + "ADD app.tar.gz /srv/", + "ADD a.txt b.txt /dst/", + "ADD [\"a b.txt\", \"/dst/\"]", + "ADD github.com/org/repo /src", + "ADD local.txt $HOME/", + "ADD < Date: Thu, 5 Nov 2026 23:43:56 -0500 Subject: [PATCH 7/8] fix(native): read JSON form as BuildKit does and add a source policy BuildKit decodes exec-form arguments with Go's json.Decoder: it reads only the first value, ignores trailing text, and maps lone surrogate escapes to U+FFFD. The scanner required strict whole-text JSON, so for `ADD ["\uD800https://x/y", "/z"]` or `RUN ["echo"] < --- .../runtime-core/src/provider/build_policy.rs | 72 +++++++++++++++++++ .../runtime-core/src/provider/build_scan.rs | 54 +++++++++----- .../src/provider/build_scan/tests.rs | 44 ++++++++++++ packages/runtime-core/src/provider/mod.rs | 1 + 4 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 packages/runtime-core/src/provider/build_policy.rs 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..e4dd2921d --- /dev/null +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -0,0 +1,72 @@ +//! BuildKit source policy for native image builds. +//! +//! The engine resolves `ADD` URLs, git sources and image blobs from the guest's own +//! network namespace, outside the build network's isolation. The Dockerfile +//! scanner refuses such sources in the Dockerfile itself, but it cannot see +//! `ONBUILD` triggers stored in a pinned base image's configuration. This policy +//! closes that gap independent of parsing: the solver evaluates every source +//! operation of the build against it, including ones a base image's triggers add, +//! and fails the build on a denied source. +//! +//! The build step writes [`document`] to a private file and passes its path in +//! `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` client, which reads +//! it for `build` and `bake` and sends it with the solve request. The document uses +//! the JSON form of BuildKit's `sourcepolicy` `Policy` message, with string enum +//! names. Rules match source identifiers by wildcard; a later matching rule wins, +//! and a source no rule matches is allowed, so `docker-image` and `local` sources +//! (the pinned contexts and the build context) still resolve. + +use serde_json::{Value, json}; + +/// Source identifier schemes that fetch over the network outside image pulls. +pub const DENIED_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; + +/// The source policy every native build runs with. +pub fn document() -> Value { + let rules: Vec = DENIED_SCHEMES + .iter() + .map(|scheme| { + json!({ + "action": "DENY", + "selector": { + "identifier": format!("{scheme}://*"), + "match_type": "WILDCARD", + }, + }) + }) + .collect(); + json!({ "version": 1, "rules": rules }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_policy_denies_every_network_source_scheme_and_nothing_else() { + let policy = document(); + assert_eq!(policy["version"], 1); + let rules = policy["rules"].as_array().unwrap(); + let identifiers: Vec<&str> = rules + .iter() + .map(|rule| { + // Only the fields BuildKit's `Rule`/`Selector` JSON tags define. + assert_eq!(rule.as_object().unwrap().len(), 2); + assert_eq!(rule["action"], "DENY"); + assert_eq!(rule["selector"]["match_type"], "WILDCARD"); + assert_eq!(rule["selector"].as_object().unwrap().len(), 2); + rule["selector"]["identifier"].as_str().unwrap() + }) + .collect(); + assert_eq!( + identifiers, + ["http://*", "https://*", "git://*", "docker-image+blob://*"] + ); + // No rule touches image or local sources, so pinned contexts still resolve. + assert!( + identifiers + .iter() + .all(|id| !id.starts_with("docker-image://") && !id.starts_with("local://")) + ); + } +} diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index abe59d7bc..f51b6f8cc 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -25,8 +25,10 @@ //! 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. Not covered here: `ONBUILD` triggers stored in a base image's own -//! configuration, which the build step must inspect on the pinned base images. +//! 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. The build step closes that with the source +//! policy in [`super::build_policy`], which denies network sources at solve time. use super::image_reference::{ImageReference, NamedReference, normalize}; use crate::CandidateError; use std::collections::{BTreeMap, BTreeSet}; @@ -565,13 +567,8 @@ fn instruction_uses( /// 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 json_form(args, line)? { - serde_json::from_str::>(args.trim_start()).map_err(|_| { - refusal( - Some(line), - "a JSON-form argument list must contain strings.", - ) - })? + let words: Vec = if let Some(words) = json_words(args, line)? { + words } else { args.split(is_frontend_whitespace) .filter(|word| !word.is_empty()) @@ -599,19 +596,40 @@ fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<() Ok(()) } -/// Whether the frontend parses these arguments as a JSON string array (exec form). -fn json_form(args: &str, line: usize) -> Result { +/// 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(false); + return Ok(None); } - match serde_json::from_str::>(args) { - Ok(values) if values.iter().all(serde_json::Value::is_string) => Ok(true), - Ok(_) => Err(refusal( + 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 contain strings.", + "a JSON-form argument list must be strict JSON without lone surrogate escapes.", )), - Err(_) => Ok(false), + _ => Ok(None), } } @@ -736,7 +754,7 @@ fn heredoc_capable( }; Ok( matches!(instruction.command.as_str(), "run" | "copy" | "add") - && !json_form(&instruction.args, line)?, + && json_words(&instruction.args, line)?.is_none(), ) } diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 7f6c147b8..b7ebfc71a 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -607,6 +607,50 @@ fn add_refuses_remote_sources() { ); } +#[test] +fn json_form_is_read_as_the_frontend_reads_it() { + // Go's decoder maps a lone surrogate to U+FFFD where strict JSON fails, so the + // frontend would see a JSON-form remote source: refuse. + for add in [ + "ADD [\"\\uD800https://x/y\", \"/z\"]", + "ADD [\"\\udfff\", \"https://x/y\", \"/z\"]", + "COPY [\"\\uD800\", \"/z\"]", + "RUN [\"\\uD800\"]", + ] { + let dockerfile = format!("FROM alpine:3.22\n{add}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{add}"); + } + // The decoder reads only the first value: text after it does not make this + // shell form, so the decoded words are the sources. + assert_eq!( + code("FROM alpine:3.22\nADD [\"https://x/y\", \"/z\"] trailing\n"), + "native_build_dockerfile" + ); + // Only the first value counts, so a malformed second one is ignored there too. + assert_eq!( + images("FROM alpine:3.22\nADD [\"a\", \"/z\"] [\"\\uD800\"]\n"), + ["alpine:3.22"] + ); + // ...and no heredoc starts there, so the next lines are instructions. + let dockerfile = + "FROM alpine:3.22\nRUN [\"echo\"] < Date: Thu, 5 Nov 2026 23:48:34 -0500 Subject: [PATCH 8/8] fix(native): allow only pinned images in the build source policy Unmatched sources are allowed, so the policy let a base image's ONBUILD trigger pull any unpinned image. `build_policy::document` now takes the build's digest-pinned base references and denies every docker-image and oci-layout source except exactly those, normalized the way BuildKit's llb.Image names a `docker-image://` named context. The http, https, git and docker-image+blob denials stay last. References that are not digest-pinned public Docker Hub images refuse. The docs now say plainly that nothing applies the policy yet: ONBUILD triggers in base images are not enforced until the build step passes it to buildx. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_policy.rs | 244 ++++++++++++++---- .../runtime-core/src/provider/build_scan.rs | 5 +- 2 files changed, 201 insertions(+), 48 deletions(-) diff --git a/packages/runtime-core/src/provider/build_policy.rs b/packages/runtime-core/src/provider/build_policy.rs index e4dd2921d..e2a37aa7f 100644 --- a/packages/runtime-core/src/provider/build_policy.rs +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -1,72 +1,224 @@ //! BuildKit source policy for native image builds. //! -//! The engine resolves `ADD` URLs, git sources and image blobs from the guest's own -//! network namespace, outside the build network's isolation. The Dockerfile -//! scanner refuses such sources in the Dockerfile itself, but it cannot see -//! `ONBUILD` triggers stored in a pinned base image's configuration. This policy -//! closes that gap independent of parsing: the solver evaluates every source -//! operation of the build against it, including ones a base image's triggers add, -//! and fails the build on a denied source. +//! 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. //! -//! The build step writes [`document`] to a private file and passes its path in -//! `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` client, which reads -//! it for `build` and `bake` and sends it with the solve request. The document uses -//! the JSON form of BuildKit's `sourcepolicy` `Policy` message, with string enum -//! names. Rules match source identifiers by wildcard; a later matching rule wins, -//! and a source no rule matches is allowed, so `docker-image` and `local` sources -//! (the pinned contexts and the build context) still resolve. +//! [`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; -/// Source identifier schemes that fetch over the network outside image pulls. -pub const DENIED_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; +/// 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 every native build runs with. -pub fn document() -> Value { - let rules: Vec = DENIED_SCHEMES +/// 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| { - json!({ - "action": "DENY", - "selector": { - "identifier": format!("{scheme}://*"), - "match_type": "WILDCARD", - }, - }) - }) + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")) .collect(); - json!({ "version": 1, "rules": rules }) + 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 the_policy_denies_every_network_source_scheme_and_nothing_else() { - let policy = document(); + 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); - let rules = policy["rules"].as_array().unwrap(); - let identifiers: Vec<&str> = rules + 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["action"], "DENY"); - assert_eq!(rule["selector"]["match_type"], "WILDCARD"); assert_eq!(rule["selector"].as_object().unwrap().len(), 2); - rule["selector"]["identifier"].as_str().unwrap() + ( + 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(); - assert_eq!( - identifiers, - ["http://*", "https://*", "git://*", "docker-image+blob://*"] - ); - // No rule touches image or local sources, so pinned contexts still resolve. - assert!( - identifiers - .iter() - .all(|id| !id.starts_with("docker-image://") && !id.starts_with("local://")) - ); + 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 index f51b6f8cc..2ed941851 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -27,8 +27,9 @@ //! 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. The build step closes that with the source -//! policy in [`super::build_policy`], which denies network sources at solve time. +//! 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};