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_policy.rs b/packages/runtime-core/src/provider/build_policy.rs new file mode 100644 index 000000000..e2a37aa7f --- /dev/null +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -0,0 +1,224 @@ +//! BuildKit source policy for native image builds. +//! +//! The engine fetches `ADD` URLs, git sources and image blobs from the guest's own +//! network namespace, outside the build network's isolation, and pulls any image a +//! build names. The Dockerfile scanner refuses remote sources in the Dockerfile +//! itself, but it cannot see `ONBUILD` triggers stored in a pinned base image's +//! configuration, which may add remote sources or name other images. +//! +//! [`document`] is the policy meant to close that gap independent of parsing: the +//! solver checks every source operation of a build against it, including ones a +//! base image's triggers add, and fails the build on a denied source. **Nothing +//! applies it yet.** Until the build step writes it to a private file and passes +//! that path in `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` +//! client (which reads it for `build` and `bake`), `ONBUILD` triggers in base +//! images are not enforced. +//! +//! The document is the JSON form of BuildKit's `sourcepolicy` `Policy` message, with +//! string enum names. A rule matches when the source identifier equals its selector +//! identifier, or, for `WILDCARD`, matches it with `*` standing for any text. The +//! last matching rule decides, and a source no rule matches is allowed. Rules, in +//! order: +//! +//! 1. deny every `docker-image://` and `oci-layout://` source; +//! 2. allow exactly each pinned base image, as the identifier BuildKit gives a +//! `docker-image://` named context: `docker-image://` and the +//! normalized `docker.io//[:tag]@sha256:`; +//! 3. deny every `http`, `https`, `git` and `docker-image+blob` source. +//! +//! `local://` sources (the build context and Dockerfile) match no rule and resolve. + +use super::image_reference::{ImageReference, normalize}; +use crate::CandidateError; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +/// Image source schemes denied unless the exact pinned identifier is allowed. +pub const IMAGE_SCHEMES: [&str; 2] = ["docker-image", "oci-layout"]; +/// Source schemes that fetch over the network, always denied. +pub const NETWORK_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; + +fn rule(action: &str, identifier: String, match_type: &str) -> Value { + json!({ + "action": action, + "selector": { "identifier": identifier, "match_type": match_type }, + }) +} + +/// The source policy a native build runs with, allowing only `pinned` images. +/// +/// Each entry is a base image reference the build passes as a +/// `docker-image://` named context. It must be a public Docker Hub +/// reference pinned by digest; it is normalized the way BuildKit's `llb.Image` +/// normalizes it, so the allow rule equals the source identifier exactly. +pub fn document<'a>(pinned: impl IntoIterator) -> Result { + let mut allowed = BTreeSet::new(); + for reference in pinned { + match normalize(reference) { + Ok(ImageReference::Named(named)) + if named.digest.is_some() && named.docker_hub_repository().is_some() => + { + allowed.insert(format!("docker-image://{}", named.canonical())); + } + _ => { + return Err(CandidateError::new( + "native_build_base_image", + "A build source policy allows only digest-pinned public Docker Hub images.", + )); + } + } + } + let mut rules: Vec = IMAGE_SCHEMES + .iter() + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")) + .collect(); + rules.extend( + allowed + .into_iter() + .map(|identifier| rule("ALLOW", identifier, "EXACT")), + ); + rules.extend( + NETWORK_SCHEMES + .iter() + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")), + ); + Ok(json!({ "version": 1, "rules": rules })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// BuildKit's `Wildcard2Regexp`: `^...$` with each `*` matching any text. + fn wildcard(pattern: &str, value: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + let (first, last) = (parts[0], parts[parts.len() - 1]); + if parts.len() == 1 { + return pattern == value; + } + if !value.starts_with(first) || value.len() < first.len() + last.len() { + return false; + } + let mut rest = &value[first.len()..value.len() - last.len()]; + if !value.ends_with(last) { + return false; + } + for middle in &parts[1..parts.len() - 1] { + match rest.find(middle) { + Some(at) => rest = &rest[at + middle.len()..], + None => return false, + } + } + true + } + + /// BuildKit's `evaluatePolicy` for ALLOW/DENY rules: the last match decides. + fn allowed(policy: &Value, identifier: &str) -> bool { + let mut deny = false; + for rule in policy["rules"].as_array().unwrap() { + let selector = &rule["selector"]; + let pattern = selector["identifier"].as_str().unwrap(); + let matched = pattern == identifier + || (selector["match_type"] == "WILDCARD" && wildcard(pattern, identifier)); + if matched { + deny = rule["action"] == "DENY"; + } + } + !deny + } + + #[test] + fn only_the_pinned_images_and_local_sources_are_allowed() { + let alpine = format!("docker.io/library/alpine@{A}"); + let tagged = format!("node:24-slim@{B}"); + let policy = document([alpine.as_str(), tagged.as_str()]).unwrap(); + assert_eq!(policy["version"], 1); + for identifier in [ + format!("docker-image://docker.io/library/alpine@{A}"), + format!("docker-image://docker.io/library/node:24-slim@{B}"), + "local://context".to_string(), + "local://dockerfile".to_string(), + ] { + assert!(allowed(&policy, &identifier), "{identifier}"); + } + for identifier in [ + // Unpinned, another digest, another repository with the same digest. + "docker-image://docker.io/library/alpine:3.22".to_string(), + "docker-image://docker.io/library/alpine:latest".to_string(), + format!("docker-image://docker.io/library/alpine@{B}"), + format!("docker-image://docker.io/library/busybox@{A}"), + format!("docker-image://docker.io/library/node@{B}"), + format!("docker-image://ghcr.io/example/app@{A}"), + format!("oci-layout://store/alpine@{A}"), + "https://example.com/a".to_string(), + "http://example.com/a".to_string(), + "git://github.com/org/repo.git".to_string(), + format!("docker-image+blob://docker.io/library/alpine@{A}"), + ] { + assert!(!allowed(&policy, &identifier), "{identifier}"); + } + } + + #[test] + fn the_rule_list_has_the_documented_order_and_shape() { + let short = format!("alpine@{A}"); + let policy = document([short.as_str(), short.as_str()]).unwrap(); + let rules: Vec<(String, String, String)> = policy["rules"] + .as_array() + .unwrap() + .iter() + .map(|rule| { + // Only the fields BuildKit's `Rule`/`Selector` JSON tags define. + assert_eq!(rule.as_object().unwrap().len(), 2); + assert_eq!(rule["selector"].as_object().unwrap().len(), 2); + ( + rule["action"].as_str().unwrap().to_string(), + rule["selector"]["identifier"].as_str().unwrap().to_string(), + rule["selector"]["match_type"].as_str().unwrap().to_string(), + ) + }) + .collect(); + let expected: Vec<(String, String, String)> = [ + ("DENY", "docker-image://*".to_string(), "WILDCARD"), + ("DENY", "oci-layout://*".to_string(), "WILDCARD"), + // Normalized like BuildKit's `llb.Image`, and listed once. + ( + "ALLOW", + format!("docker-image://docker.io/library/alpine@{A}"), + "EXACT", + ), + ("DENY", "http://*".to_string(), "WILDCARD"), + ("DENY", "https://*".to_string(), "WILDCARD"), + ("DENY", "git://*".to_string(), "WILDCARD"), + ("DENY", "docker-image+blob://*".to_string(), "WILDCARD"), + ] + .into_iter() + .map(|(a, i, m)| (a.to_string(), i, m.to_string())) + .collect(); + assert_eq!(rules, expected); + // No pinned images: every image source is denied. + let empty = document([]).unwrap(); + assert!(!allowed( + &empty, + &format!("docker-image://docker.io/library/alpine@{A}") + )); + } + + #[test] + fn only_digest_pinned_docker_hub_references_are_accepted() { + for reference in [ + "alpine:3.22".to_string(), + "alpine".to_string(), + format!("ghcr.io/example/app@{A}"), + A.to_string(), + "not a reference".to_string(), + ] { + let error = document([reference.as_str()]).unwrap_err(); + assert_eq!(error.code, "native_build_base_image", "{reference}"); + assert!(!error.message.contains(&reference), "{reference}"); + } + } +} diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs new file mode 100644 index 000000000..2ed941851 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -0,0 +1,1318 @@ +//! Pure Dockerfile image-reference scanner for native image builds. +//! +//! A native build pins every image it may pull by digest before BuildKit runs, so it +//! must know those images first. This module reads a Dockerfile, never executes it, +//! and reports the images a build of the selected target can pull: `FROM`, +//! `COPY --from=`, `RUN --mount=...,from=`, and the same forms inside +//! `ONBUILD` triggers of the Dockerfile's own stages. It follows the Dockerfile +//! frontend built into the pinned engine (BuildKit 0.30): +//! +//! - physical lines, comments, parser directives (`escape`), line continuations and +//! heredoc bodies of `RUN`, `COPY` and `ADD` are read as the frontend reads them; +//! - `FROM` names and `--platform` values expand `$NAME`/`${NAME}` from the global +//! `ARG`s declared before the first `FROM`, overridden by the build's literal +//! arguments, plus the frontend's platform arguments for a `linux/arm64` build; +//! - `COPY --from` and `RUN --mount` `from=` values are literal (the frontend refuses +//! expansion there); a value naming a stage, or a `COPY --from` stage index, is a +//! stage, `scratch` is empty, and anything else is an image; +//! - only stages reachable from the target contribute images. +//! +//! Whatever the scanner cannot resolve exactly refuses instead of guessing: a frontend +//! selected by `# syntax=` (the frontend itself would be pulled), `${NAME:-...}` and +//! other modifiers, quotes or escapes in references, unknown or uncertain variables, +//! a platform other than `linux/arm64`, an image whose name equals a stage name (a +//! pinned context of that name would replace the stage), duplicate stage names, and +//! unknown instructions or flags. Every image must be a public Docker Hub reference. +//! +//! Messages carry line numbers only; they never echo Dockerfile text or argument +//! values. `ADD` sources the engine would fetch remotely (URLs, git refs, variables +//! that could become either) refuse. Not covered here: `ONBUILD` triggers stored in +//! a base image's own configuration. They are not enforced until the build step +//! applies the source policy in [`super::build_policy`], which denies network +//! sources and unpinned images at solve time. +use super::image_reference::{ImageReference, NamedReference, normalize}; +use crate::CandidateError; +use std::collections::{BTreeMap, BTreeSet}; + +/// Largest Dockerfile the scanner reads. +pub const MAX_DOCKERFILE_BYTES: usize = 1024 * 1024; +/// Most distinct images one build may pin. +pub const MAX_IMAGE_REFERENCES: usize = 32; +/// Most build stages one Dockerfile may declare. +pub const MAX_STAGES: usize = 256; +/// Largest expanded global argument value, `FROM` name or `--platform` value. +const MAX_ARGUMENT_BYTES: usize = 4096; +/// Most bytes of expanded global argument values held at once. +const MAX_GLOBAL_ARGUMENT_BYTES: usize = 256 * 1024; + +/// Platform arguments whose values the scanner knows for a `linux/arm64` build. +const KNOWN_PLATFORM_ARGS: [(&str, &str); 4] = [ + ("BUILDOS", "linux"), + ("BUILDARCH", "arm64"), + ("TARGETOS", "linux"), + ("TARGETARCH", "arm64"), +]; +/// Frontend-defined arguments whose exact text the scanner does not assume. +const UNCERTAIN_FRONTEND_ARGS: [&str; 8] = [ + "BUILDPLATFORM", + "BUILDOSVERSION", + "BUILDVARIANT", + "TARGETPLATFORM", + "TARGETOSVERSION", + "TARGETVARIANT", + "TARGETSTAGE", + "SOURCE_DATE_EPOCH", +]; +/// `--platform` spellings that name a frontend platform argument, with its name. +const PLATFORM_VARIABLES: [(&str, &str); 4] = [ + ("$BUILDPLATFORM", "BUILDPLATFORM"), + ("${BUILDPLATFORM}", "BUILDPLATFORM"), + ("$TARGETPLATFORM", "TARGETPLATFORM"), + ("${TARGETPLATFORM}", "TARGETPLATFORM"), +]; +const KNOWN_INSTRUCTIONS: [&str; 18] = [ + "add", + "arg", + "cmd", + "copy", + "entrypoint", + "env", + "expose", + "from", + "healthcheck", + "label", + "maintainer", + "onbuild", + "run", + "shell", + "stopsignal", + "user", + "volume", + "workdir", +]; + +/// One scan: the Dockerfile bytes, the build target and the build's literal arguments. +pub struct ScanRequest<'a> { + pub dockerfile: &'a [u8], + /// Stage name to build; `None` builds the last stage. + pub target: Option<&'a str>, + /// Literal build arguments. Values are used for substitution only and never reported. + pub args: &'a BTreeMap, +} + +/// A base image the build may pull. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BaseImage { + /// The familiar name the frontend matches named build contexts against, for + /// example `node:24` or `library/alpine@sha256:...` as `alpine@sha256:...`. + pub context_name: String, + /// The normalized public Docker Hub reference. + pub reference: NamedReference, +} + +impl BaseImage { + /// The Docker Hub `namespace/repository` path. + pub fn repository(&self) -> &str { + &self.reference.path + } +} + +/// One declared build stage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Stage { + pub name: Option, + /// Whether building the target needs this stage. + pub reachable: bool, +} + +/// The scan result: stages, the selected target and the distinct images in first-use order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DockerfileScan { + pub stages: Vec, + pub target: usize, + pub images: Vec, +} + +fn refusal(line: Option, message: &str) -> CandidateError { + let message = match line { + Some(line) => format!("Dockerfile line {line}: {message}"), + None => format!("Dockerfile: {message}"), + }; + CandidateError::new("native_build_dockerfile", message) +} + +/// Scan a Dockerfile for the images a native build of `request.target` can pull. +pub fn scan(request: &ScanRequest<'_>) -> Result { + validate_arguments(request.args)?; + if request.dockerfile.len() > MAX_DOCKERFILE_BYTES { + return Err(refusal(None, "exceeds the 1 MiB scan limit.")); + } + let text = + std::str::from_utf8(request.dockerfile).map_err(|_| refusal(None, "is not UTF-8 text."))?; + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + refuse_frontend_selection(text)?; + let parsed = parse(text)?; + resolve(&parsed, request) +} + +fn validate_arguments(args: &BTreeMap) -> Result<(), CandidateError> { + for name in args.keys() { + let reserved = name.starts_with("BUILDKIT_") + || KNOWN_PLATFORM_ARGS.iter().any(|(known, _)| known == name) + || UNCERTAIN_FRONTEND_ARGS.contains(&name.as_str()); + if reserved { + return Err(CandidateError::new( + "native_build_argument", + "Build arguments may not set BuildKit frontend or platform arguments.", + )); + } + } + Ok(()) +} + +/// Refuse anything the frontend would treat as a request for another frontend: +/// `# syntax=` or `// syntax=` in the leading comment block (after an optional +/// shebang) and a JSON document. Broader than the frontend on purpose. +fn refuse_frontend_selection(text: &str) -> Result<(), CandidateError> { + if text.trim_start().starts_with('{') { + return Err(refusal(None, "JSON-form Dockerfiles are not supported.")); + } + let mut lines = text.split('\n').peekable(); + if lines.peek().is_some_and(|line| line.starts_with("#!")) { + lines.next(); + } + for line in lines { + let line = line.trim_start(); + let Some(rest) = line.strip_prefix('#').or_else(|| line.strip_prefix("//")) else { + break; + }; + let rest = rest.trim_start(); + let key_end = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[..key_end].eq_ignore_ascii_case("syntax") + && rest[key_end..].trim_start().starts_with('=') + { + return Err(refusal( + None, + "a `# syntax=` frontend directive is not supported for native builds.", + )); + } + } + Ok(()) +} + +/// A use of `COPY --from` or `RUN --mount=...,from=` with its literal value. +#[derive(Debug)] +struct Use { + line: usize, + value: String, + /// `COPY --from` accepts a stage index; mount `from=` does not. + index_allowed: bool, +} + +#[derive(Debug)] +struct RawStage { + line: usize, + base: String, + platform: Option, + name: Option, + uses: Vec, + onbuild: Vec, +} + +#[derive(Debug, Default)] +struct Parsed { + /// Global `ARG` declarations before the first `FROM`: name and raw default. + meta_args: Vec<(String, Option)>, + stages: Vec, +} + +/// The `escape` directive from the leading directive block, as the frontend reads it. +fn escape_token(lines: &[&str]) -> Result { + let mut seen = BTreeSet::new(); + let mut escape = '\\'; + for (index, raw) in lines.iter().enumerate() { + let line = trim_newline(raw).trim_start(); + let Some(rest) = line.strip_prefix('#') else { + break; + }; + let Some((key, value)) = directive(rest.trim_start()) else { + break; + }; + let key = key.to_ascii_lowercase(); + if !matches!(key.as_str(), "syntax" | "escape" | "check") { + break; + } + if !seen.insert(key.clone()) { + return Err(refusal(Some(index + 1), "a parser directive is repeated.")); + } + if key == "escape" { + escape = match value { + "\\" => '\\', + "`" => '`', + _ => return Err(refusal(Some(index + 1), "invalid escape directive.")), + }; + } + } + Ok(escape) +} + +/// `^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$` +fn directive(text: &str) -> Option<(&str, &str)> { + let first = text.chars().next()?; + if !first.is_ascii_alphabetic() { + return None; + } + let key_end = text + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(text.len()); + let rest = text[key_end..].trim_start().strip_prefix('=')?; + let value = rest.trim(); + (!value.is_empty()).then_some((&text[..key_end], value)) +} + +fn trim_newline(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +/// The frontend's continuation rule: a trailing escape token (optionally followed by +/// spaces or tabs) continues the line unless the token itself is escaped. +fn trim_continuation(line: &str, escape: char) -> (&str, bool) { + let trimmed = line.trim_end_matches([' ', '\t']); + if let Some(before) = trimmed.strip_suffix(escape) { + if before.is_empty() { + return ("", false); + } + if !before.ends_with(escape) { + return (before, false); + } + } + (line, true) +} + +fn is_frontend_whitespace(c: char) -> bool { + matches!(c, '\t' | '\u{b}' | '\u{c}' | '\r' | ' ') +} + +struct Instruction { + command: String, + flags: Vec, + args: String, +} + +/// Split `COMMAND [--flags] args` the way the frontend's `splitCommand` does. +fn split_instruction(line: &str, escape: char) -> Instruction { + let line = line.trim(); + let (command, rest) = match line.find(is_frontend_whitespace) { + Some(at) => ( + &line[..at], + line[at..].trim_start_matches(is_frontend_whitespace), + ), + None => (line, ""), + }; + let (args, flags) = if rest.is_empty() { + (String::new(), Vec::new()) + } else { + extract_flags(rest, escape) + }; + Instruction { + command: command.to_ascii_lowercase(), + flags, + args: args.trim().to_string(), + } +} + +/// Port of the frontend's `extractBuilderFlags`: leading `--name[=value]` words with +/// quotes removed and escapes applied; returns the remaining text and the flag words. +fn extract_flags(line: &str, escape: char) -> (String, Vec) { + #[derive(PartialEq)] + enum Phase { + Spaces, + Word, + Quote, + } + let chars: Vec<(usize, char)> = line.char_indices().collect(); + let mut words = Vec::new(); + let mut phase = Phase::Spaces; + let mut word = String::new(); + let mut quote = '\0'; + let mut blank_ok = false; + let mut pos = 0; + loop { + let at_end = pos == chars.len(); + if phase == Phase::Spaces { + if at_end { + break; + } + let ch = chars[pos].1; + if ch.is_whitespace() { + pos += 1; + continue; + } + if ch != '-' || pos + 1 == chars.len() || chars[pos + 1].1 != '-' { + return (line[chars[pos].0..].to_string(), words); + } + phase = Phase::Word; + } + if at_end { + if word != "--" && (blank_ok || !word.is_empty()) { + words.push(word); + } + break; + } + let mut ch = chars[pos].1; + if phase == Phase::Word { + if ch.is_whitespace() { + phase = Phase::Spaces; + if word == "--" { + return (line[chars[pos].0..].to_string(), words); + } + if blank_ok || !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + word.clear(); + blank_ok = false; + pos += 1; + continue; + } + if ch == '\'' || ch == '"' { + quote = ch; + blank_ok = true; + phase = Phase::Quote; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + continue; + } + // Phase::Quote + if ch == quote { + phase = Phase::Word; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + phase = Phase::Word; + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + } + (String::new(), words) +} + +#[derive(Clone, Copy, PartialEq)] +enum FlagKind { + Value, + Bool, + Repeated, +} + +/// Parse flag words against an instruction's flag set; returns values by flag name. +/// Unknown, duplicated or malformed flags refuse, as the frontend would fail on them. +fn parse_flags( + words: &[String], + allowed: &[(&str, FlagKind)], + line: usize, +) -> Result>, CandidateError> { + let mut values: BTreeMap> = BTreeMap::new(); + for word in words { + let Some(body) = word.strip_prefix("--") else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let (name, value) = match body.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (body, None), + }; + let Some((_, kind)) = allowed.iter().find(|(known, _)| *known == name) else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let entry = values.entry(name.to_string()).or_default(); + let malformed = match kind { + FlagKind::Value => value.is_none() || !entry.is_empty(), + FlagKind::Bool => { + !entry.is_empty() + || value.is_some_and(|v| !matches!(v, "true" | "false" | "1" | "0")) + } + FlagKind::Repeated => value.is_none(), + }; + if malformed { + return Err(refusal( + Some(line), + "malformed or repeated instruction flag.", + )); + } + entry.push(value.unwrap_or("").to_string()); + } + Ok(values) +} + +const FROM_FLAGS: [(&str, FlagKind); 1] = [("platform", FlagKind::Value)]; +const COPY_FLAGS: [(&str, FlagKind); 6] = [ + ("chown", FlagKind::Value), + ("from", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("exclude", FlagKind::Repeated), + ("parents", FlagKind::Bool), +]; +const ADD_FLAGS: [(&str, FlagKind); 7] = [ + ("chown", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("keep-git-dir", FlagKind::Bool), + ("checksum", FlagKind::Value), + ("unpack", FlagKind::Bool), + ("exclude", FlagKind::Repeated), +]; +const RUN_FLAGS: [(&str, FlagKind); 4] = [ + ("mount", FlagKind::Repeated), + ("network", FlagKind::Value), + ("security", FlagKind::Value), + ("device", FlagKind::Repeated), +]; + +/// A literal `--from`/`from=` value: anything the frontend would expand or unquote refuses. +fn literal_source(value: &str, escape: char, line: usize) -> Result { + if value.is_empty() + || value + .chars() + .any(|c| matches!(c, '$' | '\'' | '"' | '\\' | '`') || c == escape || c.is_whitespace()) + { + return Err(refusal( + Some(line), + "a build source must be a literal stage or image name without variables or quotes.", + )); + } + Ok(value.to_string()) +} + +/// Image and stage uses of one `COPY`/`ADD`/`RUN` instruction. +fn instruction_uses( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result, CandidateError> { + let mut uses = Vec::new(); + match instruction.command.as_str() { + "copy" => { + let flags = parse_flags(&instruction.flags, ©_FLAGS, line)?; + if let Some(value) = flags.get("from").and_then(|v| v.first()) { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: true, + }); + } + } + "add" => { + let flags = parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + if flags.contains_key("checksum") || flags.contains_key("keep-git-dir") { + return Err(refusal( + Some(line), + "ADD --checksum and --keep-git-dir apply only to remote sources, which native builds do not fetch.", + )); + } + refuse_remote_add_sources(&instruction.args, escape, line)?; + } + "run" => { + let flags = parse_flags(&instruction.flags, &RUN_FLAGS, line)?; + for mount in flags.get("mount").into_iter().flatten() { + if mount.contains('"') { + return Err(refusal( + Some(line), + "quoted mount options are not supported.", + )); + } + for field in mount.split(',') { + if let Some((key, value)) = field.split_once('=') { + if key.eq_ignore_ascii_case("from") { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: false, + }); + } + } + } + } + } + _ => {} + } + Ok(uses) +} + +/// Refuse an `ADD` that could fetch a remote source. +/// +/// The engine fetches `ADD` URLs and git refs from the guest's own network +/// namespace, outside the build network's isolation, so a native build may add +/// only local build-context files. The frontend splits a shell-form `ADD` on +/// whitespace (or reads a JSON string array), takes every word but the last as a +/// source, and expands variables in each. A source is remote when it starts with +/// `scheme://` (http, https, git, ssh) or is an scp-style `user@host:path` git ref. +/// This refuses a superset: after dropping quotes and escapes, any source with +/// `://`, with `@` followed by `:`, or with a `$` that could expand to either. +fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<(), CandidateError> { + let words: Vec = if let Some(words) = json_words(args, line)? { + words + } else { + args.split(is_frontend_whitespace) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect() + }; + // Every word but the destination; a lone word is checked as well. + let sources = match words.len() { + 0 | 1 => &words[..], + count => &words[..count - 1], + }; + for source in sources { + let bare: String = source + .chars() + .filter(|&c| !matches!(c, '\'' | '"' | '\\') && c != escape) + .collect(); + let scp_like = bare.find('@').is_some_and(|at| bare[at..].contains(':')); + if bare.contains("://") || scp_like || bare.contains('$') { + return Err(refusal( + Some(line), + "ADD may use only local build context sources, without URLs, git refs or variables.", + )); + } + } + Ok(()) +} + +/// The words of a JSON-form (exec-form) argument list, or `None` for shell form. +/// +/// The frontend's `parseJSON` trims leading whitespace, requires `[`, and decodes +/// with Go's `json.Decoder`: it reads only the first value and ignores what +/// follows, and it decodes lone UTF-16 surrogate escapes leniently to U+FFFD. Its +/// result decides whether heredocs apply and which words are `ADD` sources, so a +/// disagreement could hide instructions or sources. The first value is decoded the +/// same way, trailing text included. Where the strict decoder fails on text that +/// has a `\u` escape, the frontend may still accept it, so that refuses. +fn json_words(args: &str, line: usize) -> Result>, CandidateError> { + let args = args.trim_start(); + if !args.starts_with('[') { + return Ok(None); + } + let first = serde_json::Deserializer::from_str(args) + .into_iter::() + .next(); + match first { + Some(Ok(serde_json::Value::Array(values))) => values + .into_iter() + .map(|value| match value { + serde_json::Value::String(word) => Ok(word), + _ => Err(refusal( + Some(line), + "a JSON-form argument list must contain strings.", + )), + }) + .collect::, _>>() + .map(Some), + _ if args.contains("\\u") => Err(refusal( + Some(line), + "a JSON-form argument list must be strict JSON without lone surrogate escapes.", + )), + _ => Ok(None), + } +} + +/// Shell words with quotes and escapes kept raw, as the heredoc detector lexes a line. +/// +/// Like the frontend's lexer, an unquoted `<<` keeps the tabs, carriage returns and +/// spaces after it in the same word, so `<< EOF` is one heredoc word. A `-` stops +/// that, so `<<- EOF` is two words and not a heredoc. +fn raw_words(line: &str) -> Option> { + let mut words = Vec::new(); + let mut word = String::new(); + let mut in_word = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '<' if chars.peek() == Some(&'<') => { + chars.next(); + word.push_str("<<"); + while let Some(&space) = chars.peek() { + if !matches!(space, '\t' | '\r' | ' ') { + break; + } + word.push(space); + chars.next(); + } + in_word = true; + } + c if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut word)); + in_word = false; + } + } + '\\' => { + word.push(c); + word.push(chars.next()?); + in_word = true; + } + '\'' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\'' { + break; + } + } + in_word = true; + } + '"' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\\' { + word.push(chars.next()?); + } else if next == '"' { + break; + } + } + in_word = true; + } + _ => { + word.push(c); + in_word = true; + } + } + } + if in_word { + words.push(word); + } + Some(words) +} + +/// A heredoc word, `^(\d*)<<(-?)\s*([^<]*)$`: its terminator and whether tabs are chomped. +fn heredoc(word: &str, line: usize) -> Result, CandidateError> { + let rest = word.trim_start_matches(|c: char| c.is_ascii_digit()); + let Some(rest) = rest.strip_prefix("<<") else { + return Ok(None); + }; + let (rest, chomp) = match rest.strip_prefix('-') { + Some(rest) => (rest, true), + None => (rest, false), + }; + let rest = rest.trim_start_matches(['\t', '\n', '\u{c}', '\r', ' ']); + if rest.is_empty() || rest.contains('<') { + return Ok(None); + } + if rest.contains('\\') { + return Err(refusal( + Some(line), + "escaped heredoc delimiters are not supported.", + )); + } + let mut name = String::new(); + let mut quote = None; + for c in rest.chars() { + match (quote, c) { + (None, '\'' | '"') => quote = Some(c), + (Some(open), c) if c == open => quote = None, + (None, c) if c.is_whitespace() => return Ok(None), + _ => name.push(c), + } + } + if quote.is_some() || name.is_empty() { + return Err(refusal(Some(line), "unrecognized heredoc delimiter.")); + } + Ok(Some((name, chomp))) +} + +fn heredoc_capable( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result { + let inner; + let instruction = if instruction.command == "onbuild" { + inner = split_instruction(&instruction.args, escape); + &inner + } else { + instruction + }; + Ok( + matches!(instruction.command.as_str(), "run" | "copy" | "add") + && json_words(&instruction.args, line)?.is_none(), + ) +} + +fn parse(text: &str) -> Result { + let lines: Vec<&str> = text.split('\n').collect(); + let escape = escape_token(&lines)?; + let mut parsed = Parsed::default(); + let mut index = 0; + while index < lines.len() { + let start = index + 1; + let first = trim_newline(lines[index]).trim_start(); + index += 1; + if first.starts_with('#') { + continue; + } + let (first, mut end) = trim_continuation(first, escape); + if end && first.is_empty() { + continue; + } + let mut logical = first.to_string(); + while !end && index < lines.len() { + let raw = trim_newline(lines[index]); + index += 1; + let trimmed = raw.trim_start(); + if trimmed.starts_with('#') || trimmed.is_empty() { + continue; + } + let (part, done) = trim_continuation(raw, escape); + logical.push_str(part); + end = done; + } + let instruction = split_instruction(&logical, escape); + if heredoc_capable(&instruction, escape, start)? && logical.contains("<<") { + let words = + raw_words(&logical).ok_or_else(|| refusal(Some(start), "unterminated quote."))?; + for word in words { + let Some((terminator, chomp)) = heredoc(&word, start)? else { + continue; + }; + let mut terminated = false; + while index < lines.len() { + let body = trim_newline(lines[index]); + index += 1; + let body = if chomp { + body.trim_start_matches('\t') + } else { + body + }; + if body == terminator { + terminated = true; + break; + } + } + if !terminated { + return Err(refusal(Some(start), "unterminated heredoc.")); + } + } + } + dispatch(&mut parsed, instruction, escape, start)?; + } + if parsed.stages.is_empty() { + return Err(refusal(None, "declares no build stage.")); + } + Ok(parsed) +} + +fn dispatch( + parsed: &mut Parsed, + instruction: Instruction, + escape: char, + line: usize, +) -> Result<(), CandidateError> { + let command = instruction.command.as_str(); + if !KNOWN_INSTRUCTIONS.contains(&command) { + return Err(refusal(Some(line), "unknown instruction.")); + } + if parsed.stages.is_empty() && command != "arg" && command != "from" { + return Err(refusal(Some(line), "only ARG may precede the first FROM.")); + } + match command { + "from" => { + let flags = parse_flags(&instruction.flags, &FROM_FLAGS, line)?; + let words: Vec<&str> = instruction + .args + .split(is_frontend_whitespace) + .filter(|w| !w.is_empty()) + .collect(); + let name = match words.as_slice() { + [_] => None, + [_, keyword, name] if keyword.eq_ignore_ascii_case("as") => { + let name = name.to_ascii_lowercase(); + let valid = name.starts_with(|c: char| c.is_ascii_lowercase()) + && name.chars().all(|c| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || matches!(c, '-' | '_' | '.') + }); + if !valid { + return Err(refusal(Some(line), "invalid build stage name.")); + } + // The frontend links a later `FROM scratch` (or `--from=scratch`) to + // a stage of that name instead of the empty image. + if name == "scratch" { + return Err(refusal( + Some(line), + "a build stage may not be named scratch.", + )); + } + Some(name) + } + _ => return Err(refusal(Some(line), "FROM must name one image or stage.")), + }; + if name + .as_ref() + .is_some_and(|name| parsed.stages.iter().any(|s| s.name.as_ref() == Some(name))) + { + return Err(refusal(Some(line), "duplicate build stage name.")); + } + if parsed.stages.len() == MAX_STAGES { + return Err(refusal(Some(line), "declares more than 256 build stages.")); + } + parsed.stages.push(RawStage { + line, + base: words[0].to_string(), + platform: flags.get("platform").and_then(|v| v.first()).cloned(), + name, + uses: Vec::new(), + onbuild: Vec::new(), + }); + } + "arg" => { + let words = argument_words(&instruction.args, escape); + if words.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + for word in words { + let (name, value) = match word.split_once('=') { + Some((name, value)) => (name.to_string(), Some(value.to_string())), + None => (word, None), + }; + if name.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + // The frontend would unquote, unescape or expand any other name, so a + // literal compare could miss an override of a platform argument. + if !is_identifier(&name) { + return Err(refusal( + Some(line), + "ARG names must be plain [A-Za-z_][A-Za-z0-9_]* identifiers.", + )); + } + if parsed.stages.is_empty() { + parsed.meta_args.push((name, value)); + } + } + } + "onbuild" => { + if !instruction.flags.is_empty() { + return Err(refusal(Some(line), "unsupported instruction flag.")); + } + let inner = split_instruction(&instruction.args, escape); + if !KNOWN_INSTRUCTIONS.contains(&inner.command.as_str()) + || matches!(inner.command.as_str(), "onbuild" | "from" | "maintainer") + { + return Err(refusal(Some(line), "unsupported ONBUILD trigger.")); + } + let uses = instruction_uses(&inner, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.onbuild.extend(uses); + } + } + _ => { + let uses = instruction_uses(&instruction, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.uses.extend(uses); + } + } + } + Ok(()) +} + +/// Port of the frontend's `parseWords`: whitespace-separated words, quotes kept, +/// the escape token keeping the character it escapes. +fn argument_words(text: &str, escape: char) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut started = false; + let mut quote: Option = None; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + None if c.is_whitespace() => { + if started { + words.push(std::mem::take(&mut word)); + started = false; + } + } + None => { + started = true; + if c == '\'' || c == '"' { + quote = Some(c); + word.push(c); + } else if c == escape { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + Some(open) => { + if c == open { + quote = None; + word.push(c); + } else if c == escape && open != '\'' { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + } + } + if started { + words.push(word); + } + words +} + +fn is_identifier(name: &str) -> bool { + name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// A global argument value: known text, or present with text the scanner cannot know. +type Env = BTreeMap>; + +/// Append `piece` to `out` unless that would pass `cap` bytes. Checked before the +/// append, so an oversized value never allocates past the cap. +fn push_bounded(out: &mut String, piece: &str, cap: usize) -> Option<()> { + if out.len().checked_add(piece.len())? > cap { + return None; + } + out.push_str(piece); + Some(()) +} + +/// Expand `$NAME` and `${NAME}` into at most `cap` bytes. Any other `$` form, quote, +/// escape, unknown or uncertain variable, or a result over `cap` yields `None`. +fn expand(word: &str, env: &Env, cap: usize) -> Option { + let mut out = String::new(); + let mut chars = word.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '$' => { + let braced = chars.peek() == Some(&'{'); + if braced { + chars.next(); + } + let mut name = String::new(); + while let Some(&next) = chars.peek() { + if next.is_ascii_alphanumeric() || next == '_' { + name.push(next); + chars.next(); + } else { + break; + } + } + if braced && chars.next() != Some('}') { + return None; + } + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + push_bounded(&mut out, env.get(&name)?.as_deref()?, cap)?; + } + '\'' | '"' | '\\' | '`' => return None, + _ => push_bounded(&mut out, c.encode_utf8(&mut [0; 4]), cap)?, + } + } + Some(out) +} + +/// The value of a global `ARG` default, in at most `cap` bytes: quotes removed, +/// `$NAME`/`${NAME}` expanded. Anything else, including a value over `cap`, is +/// recorded as uncertain and refuses only when a reference uses it. +fn argument_value(raw: &str, env: &Env, cap: usize) -> Option { + let mut out = String::new(); + let mut rest = raw; + while let Some(c) = rest.chars().next() { + let remaining = cap - out.len(); + match c { + '\'' => { + let body = &rest[1..]; + let end = body.find('\'')?; + push_bounded(&mut out, &body[..end], cap)?; + rest = &body[end + 1..]; + } + '"' => { + let body = &rest[1..]; + let end = body.find('"')?; + out.push_str(&expand(&body[..end], env, remaining)?); + rest = &body[end + 1..]; + } + _ => { + let end = rest.find(['\'', '"']).unwrap_or(rest.len()); + out.push_str(&expand(&rest[..end], env, remaining)?); + rest = &rest[end..]; + } + } + } + Some(out) +} + +/// The global arguments `FROM` names and `--platform` values expand against. +/// +/// Defaults expand eagerly, so `ARG A=$A$A` repeated would double without bound. +/// Each expanded default is capped at [`MAX_ARGUMENT_BYTES`] and all held values at +/// [`MAX_GLOBAL_ARGUMENT_BYTES`]; a default past either cap is uncertain. +fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { + let mut env: Env = KNOWN_PLATFORM_ARGS + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(); + for name in UNCERTAIN_FRONTEND_ARGS { + env.insert(name.to_string(), None); + } + let mut held: usize = KNOWN_PLATFORM_ARGS.iter().map(|(_, v)| v.len()).sum(); + for (name, default) in &parsed.meta_args { + let value = if let Some(value) = args.get(name) { + Some(value.clone()) + } else if let Some(default) = default { + let cap = MAX_ARGUMENT_BYTES.min(MAX_GLOBAL_ARGUMENT_BYTES.saturating_sub(held)); + argument_value(default, &env, cap) + } else { + continue; + }; + held += value.as_ref().map_or(0, String::len); + if let Some(Some(old)) = env.insert(name.clone(), value) { + held -= old.len(); + } + } + // The frontend rewrites this one from the resolved build epoch. + env.insert("SOURCE_DATE_EPOCH".into(), None); + env +} + +#[derive(Clone, Debug)] +enum Source { + Scratch, + Stage(usize), + Image(String), +} + +/// Whether a reachable stage's `--platform` selects `linux/arm64`. +/// +/// `$BUILDPLATFORM` and `$TARGETPLATFORM` select it only while they keep the +/// frontend's value. A global `ARG` default replaces that value (the frontend +/// expands `--platform` against the global arguments), so an overridden one must +/// itself expand to `linux/arm64`. A bare global `ARG` keeps the frontend's value. +fn platform_supported(raw: &str, env: &Env, parsed: &Parsed) -> bool { + let frontend_value = PLATFORM_VARIABLES + .iter() + .find(|(spelling, _)| *spelling == raw) + .is_some_and(|(_, name)| { + !parsed + .meta_args + .iter() + .any(|(declared, default)| declared == name && default.is_some()) + }); + frontend_value + || expand(raw, env, MAX_ARGUMENT_BYTES) + .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) +} + +fn stage_by_name(parsed: &Parsed, name: &str) -> Option { + let name = name.to_ascii_lowercase(); + parsed + .stages + .iter() + .position(|stage| stage.name.as_deref() == Some(name.as_str())) +} + +fn use_source(parsed: &Parsed, source: &Use) -> Result { + let value = source.value.as_str(); + if source.index_allowed && value.bytes().all(|b| b.is_ascii_digit()) { + return value + .parse::() + .ok() + .filter(|index| *index < parsed.stages.len()) + .map(Source::Stage) + .ok_or_else(|| refusal(Some(source.line), "invalid build stage index.")); + } + if source.index_allowed && value.starts_with(['+', '-']) { + return Err(refusal(Some(source.line), "invalid build stage index.")); + } + if let Some(index) = stage_by_name(parsed, value) { + return Ok(Source::Stage(index)); + } + if value == "scratch" { + return Ok(Source::Scratch); + } + Ok(Source::Image(value.to_string())) +} + +fn base_image(name: &str, line: usize) -> Result { + match normalize(name) { + Ok(ImageReference::Named(reference)) if reference.docker_hub_repository().is_some() => { + let path = reference + .path + .strip_prefix("library/") + .unwrap_or(&reference.path); + let mut familiar = path.to_string(); + if let Some(tag) = &reference.tag { + familiar.push(':'); + familiar.push_str(tag); + } + if let Some(digest) = &reference.digest { + familiar.push('@'); + familiar.push_str(digest); + } + let context_name = familiar + .strip_suffix(":latest") + .map_or(familiar.clone(), str::to_string); + Ok(BaseImage { + context_name, + reference, + }) + } + _ => Err(CandidateError::new( + "native_build_base_image", + format!( + "Dockerfile line {line}: build images must be public Docker Hub [namespace/]repository[:tag][@sha256] references." + ), + )), + } +} + +fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result { + let env = global_env(parsed, request.args); + // Each stage's base as the frontend registers it: an earlier stage of that name, + // `scratch`, or an image. `None` marks a name the scanner cannot expand. + let mut bases: Vec> = Vec::with_capacity(parsed.stages.len()); + for (index, stage) in parsed.stages.iter().enumerate() { + let base = expand(&stage.base, &env, MAX_ARGUMENT_BYTES) + .filter(|name| !name.is_empty()) + .map(|name| { + 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, parsed) { + return Err(refusal( + Some(stage.line), + "native builds support only the linux/arm64 platform.", + )); + } + } + let mut sources = Vec::new(); + match &bases[index] { + Some(base) => sources.push((base.clone(), stage.line)), + None => { + return Err(refusal( + Some(stage.line), + "the FROM name uses a variable the scanner cannot resolve.", + )); + } + } + for used in &stage.uses { + sources.push((use_source(parsed, used)?, used.line)); + } + // ONBUILD triggers of every ancestor stage run in this stage (a superset of the + // frontend's inheritance, so no trigger image is missed). + let mut ancestor = &bases[index]; + while let Some(Source::Stage(parent)) = ancestor { + for used in &parsed.stages[*parent].onbuild { + sources.push((use_source(parsed, used)?, used.line)); + } + ancestor = &bases[*parent]; + } + for (source, line) in sources { + match source { + Source::Scratch => {} + Source::Stage(next) => pending.push(next), + Source::Image(name) => images.push((name, line)), + } + } + } + + let mut admitted: Vec = Vec::new(); + let mut lines: Vec<(usize, BaseImage)> = Vec::new(); + for (name, line) in images { + lines.push((line, base_image(&name, line)?)); + } + lines.sort_by_key(|(line, _)| *line); + for (line, image) in lines { + if parsed + .stages + .iter() + .any(|stage| stage.name.as_deref() == Some(image.context_name.as_str())) + { + return Err(refusal( + Some(line), + "an image name equals a build stage name; rename the stage.", + )); + } + if !admitted + .iter() + .any(|known| known.reference == image.reference) + { + if admitted.len() == MAX_IMAGE_REFERENCES { + return Err(refusal(None, "uses more than 32 distinct images.")); + } + admitted.push(image); + } + } + Ok(DockerfileScan { + stages: parsed + .stages + .iter() + .zip(reachable) + .map(|(stage, reachable)| Stage { + name: stage.name.clone(), + reachable, + }) + .collect(), + target, + images: admitted, + }) +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs new file mode 100644 index 000000000..b7ebfc71a --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -0,0 +1,773 @@ +use super::*; + +fn scan_with( + dockerfile: &str, + target: Option<&str>, + args: &[(&str, &str)], +) -> Result { + let args: BTreeMap = args + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + scan(&ScanRequest { + dockerfile: dockerfile.as_bytes(), + target, + args: &args, + }) +} + +fn images(dockerfile: &str) -> Vec { + names(&scan_with(dockerfile, None, &[]).unwrap()) +} + +fn names(scan: &DockerfileScan) -> Vec { + scan.images.iter().map(|i| i.context_name.clone()).collect() +} + +fn code(dockerfile: &str) -> &'static str { + scan_with(dockerfile, None, &[]).unwrap_err().code +} + +#[test] +fn a_multi_stage_build_reports_base_images_and_not_stages() { + let dockerfile = "\ +ARG RUNTIME_VERSION=24 +FROM example/toolchain:1.2 AS build +WORKDIR /src +COPY . . +RUN make + +FROM node:${RUNTIME_VERSION}-slim AS runtime +COPY --from=build /src/out /app +COPY --from=busybox:1.37 /bin/busybox /bin/busybox +CMD [\"node\", \"/app/index.js\"] +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["example/toolchain:1.2", "node:24-slim", "busybox:1.37"] + ); + assert_eq!(scan.target, 1); + assert!(scan.stages.iter().all(|stage| stage.reachable)); + assert_eq!(scan.images[1].repository(), "library/node"); + assert_eq!(scan.images[1].reference.tag.as_deref(), Some("24-slim")); +} + +#[test] +fn literal_build_arguments_override_global_defaults_and_chain() { + let dockerfile = "\ +ARG DISTRO=bookworm +ARG BASE=debian:${DISTRO} +FROM $BASE +"; + assert_eq!(images(dockerfile), ["debian:bookworm"]); + let scan = scan_with(dockerfile, None, &[("DISTRO", "trixie")]).unwrap(); + assert_eq!(names(&scan), ["debian:trixie"]); + // An argument overrides the whole default, verbatim. + let scan = scan_with(dockerfile, None, &[("BASE", "alpine:3.22")]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + // Quoted defaults lose their quotes as the frontend does. + assert_eq!( + images("ARG V=\"3.22\"\nARG R='alpine'\nFROM ${R}:$V\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn stage_args_do_not_reach_from_lines() { + // A stage ARG is not a global ARG, so the second FROM cannot resolve it. + let dockerfile = "FROM alpine:3.22 AS one\nARG TAG=1\nFROM busybox:$TAG\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + // An undeclared build argument does not become a global ARG. + assert!(scan_with("FROM busybox:$TAG\n", None, &[("TAG", "1")]).is_err()); +} + +#[test] +fn platform_arguments_known_for_arm64_resolve_and_uncertain_ones_refuse() { + assert_eq!( + images("FROM example/tool:${TARGETOS}-${TARGETARCH}\n"), + ["example/tool:linux-arm64"] + ); + assert_eq!( + code("FROM example/tool:${TARGETVARIANT}\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("ARG SOURCE_DATE_EPOCH=1\nFROM example/tool:$SOURCE_DATE_EPOCH\n"), + "native_build_dockerfile" + ); +} + +#[test] +fn variable_modifiers_quotes_and_unknown_variables_refuse() { + for dockerfile in [ + "ARG V=1\nFROM alpine:${V:-3}\n", + "ARG V=1\nFROM alpine:${V:+3}\n", + "FROM alpine:$UNDECLARED\n", + "ARG V\nFROM alpine:$V\n", + "FROM \"alpine\"\n", + "ARG V=1\nFROM alpine:${V\n", + "ARG V=${OTHER:-x}\nFROM alpine:$V\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An unresolvable default is harmless while nothing uses it. + assert_eq!( + images("ARG V=${OTHER:-x}\nFROM alpine:3.22\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn copy_from_resolves_stage_names_indexes_and_images() { + let dockerfile = "\ +FROM alpine:3.22 AS Base +FROM busybox:1.37 +COPY --from=BASE /a /a +COPY --from=0 /b /b +COPY --from=scratch /c /c +COPY --from=example/assets:7 /d /d +COPY --link --chmod=0644 --from=example/assets:7 /e /e +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["alpine:3.22", "busybox:1.37", "example/assets:7"] + ); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + for invalid in ["COPY --from=9 /a /a", "COPY --from=-1 /a /a"] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{invalid}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn from_values_are_literal_as_the_frontend_requires() { + for line in [ + "COPY --from=$IMAGE /a /a", + "COPY --from=${IMAGE} /a /a", + "COPY --from=\"$IMAGE\" /a /a", + "COPY --from=a\\$b /a /a", + "RUN --mount=type=bind,from=$IMAGE,target=/x true", + ] { + let dockerfile = format!("ARG IMAGE=alpine:3.22\nFROM busybox:1.37\n{line}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{line}"); + } +} + +#[test] +fn run_mounts_resolve_stage_names_and_images_but_not_indexes() { + let dockerfile = "\ +FROM golang:1.25 AS deps +FROM busybox:1.37 +RUN --mount=type=cache,target=/cache \\ + --mount=type=bind,from=deps,source=/go,target=/go \\ + --mount=type=bind,FROM=example/data:2,target=/data \\ + --mount=type=bind,from=0,target=/zero \\ + true +"; + assert_eq!( + images(dockerfile), + ["golang:1.25", "busybox:1.37", "example/data:2", "0"] + ); + // Quotes around the whole flag are removed before the mount options are read. + assert_eq!( + images("FROM busybox:1.37\nRUN --mount=\"type=bind,from=example/data:2,target=/d\" true\n"), + ["busybox:1.37", "example/data:2"] + ); +} + +#[test] +fn only_stages_reachable_from_the_target_contribute_images() { + let dockerfile = "\ +FROM example/lint:1 AS lint +FROM golang:1.25 AS build +COPY --from=example/proto:3 /p /p +FROM build AS test +RUN go test ./... +FROM gcr.io/distroless/static AS release +COPY --from=build /out /out +"; + let scan = scan_with(dockerfile, Some("TEST"), &[]).unwrap(); + assert_eq!(names(&scan), ["golang:1.25", "example/proto:3"]); + assert_eq!(scan.target, 2); + assert_eq!( + scan.stages.iter().map(|s| s.reachable).collect::>(), + [false, true, true, false] + ); + // The default target is the last stage, whose base is not Docker Hub. + assert_eq!( + scan_with(dockerfile, None, &[]).unwrap_err().code, + "native_build_base_image" + ); + assert_eq!( + scan_with(dockerfile, Some("missing"), &[]) + .unwrap_err() + .code, + "native_build_dockerfile" + ); +} + +#[test] +fn a_later_stage_name_used_by_an_earlier_from_is_an_image_and_refuses_as_ambiguous() { + // The frontend matches FROM only against earlier stages, so `FROM base` here is the + // image `library/base`; a pinned context named `base` would replace the stage. + let dockerfile = "FROM base AS first\nFROM alpine:3.22 AS base\nFROM first\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + assert_eq!(code("FROM node AS node\n"), "native_build_dockerfile"); +} + +#[test] +fn duplicate_or_invalid_stage_names_refuse() { + assert_eq!( + code("FROM alpine:3.22 AS a\nFROM busybox:1.37 AS A\n"), + "native_build_dockerfile" + ); + assert_eq!(code("FROM alpine:3.22 AS 1st\n"), "native_build_dockerfile"); + assert_eq!(code("FROM alpine:3.22 AS\n"), "native_build_dockerfile"); +} + +#[test] +fn onbuild_triggers_of_internal_base_stages_are_scanned() { + let dockerfile = "\ +FROM alpine:3.22 AS template +ONBUILD COPY --from=example/plugin:5 /p /p +ONBUILD RUN --mount=type=bind,from=example/tools:6,target=/t true +FROM template AS child +FROM child +"; + assert_eq!( + images(dockerfile), + ["alpine:3.22", "example/plugin:5", "example/tools:6"] + ); + // A trigger in the target stage itself only affects images built from it. + assert_eq!( + images("FROM alpine:3.22\nONBUILD COPY --from=example/plugin:5 /p /p\n"), + ["alpine:3.22"] + ); + for trigger in [ + "ONBUILD FROM alpine:3.22", + "ONBUILD ONBUILD RUN true", + "ONBUILD BOGUS x", + ] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{trigger}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn platform_flags_must_name_arm64_or_the_build_platforms() { + for accepted in [ + "--platform=linux/arm64", + "--platform=linux/arm64/v8", + "--platform=$BUILDPLATFORM", + "--platform=${TARGETPLATFORM}", + ] { + assert_eq!( + images(&format!("FROM {accepted} alpine:3.22\n")), + ["alpine:3.22"], + "{accepted}" + ); + } + assert_eq!( + images("ARG ARCH=arm64\nFROM --platform=linux/${ARCH} alpine:3.22\n"), + ["alpine:3.22"] + ); + for refused in [ + "--platform=linux/amd64", + "--platform=${BUILDPLATFORM:-linux/amd64}", + "--platform=$UNSET", + "--platform", + ] { + assert_eq!( + code(&format!("FROM {refused} alpine:3.22\n")), + "native_build_dockerfile", + "{refused}" + ); + } + // An unreachable stage's platform does not matter. + let dockerfile = "FROM --platform=linux/amd64 alpine:3.22 AS other\nFROM busybox:1.37\n"; + assert_eq!(images(dockerfile), ["busybox:1.37"]); +} + +#[test] +fn overridden_platform_arguments_must_resolve_to_arm64() { + // A global default replaces the frontend's platform value, so the spelling + // alone no longer selects linux/arm64. + for dockerfile in [ + "ARG BUILDPLATFORM=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG TARGETPLATFORM=linux/amd64\nFROM --platform=${TARGETPLATFORM} alpine:3.22\n", + "ARG P=linux/amd64\nARG BUILDPLATFORM=$P\nFROM --platform=${BUILDPLATFORM} alpine:3.22\n", + "ARG BUILDPLATFORM=${OTHER:-linux/arm64}\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG TARGETARCH=amd64\nFROM --platform=linux/$TARGETARCH alpine:3.22\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An override that resolves to linux/arm64 still selects it. + assert_eq!( + images("ARG TARGETPLATFORM=linux/arm64\nFROM --platform=$TARGETPLATFORM alpine:3.22\n"), + ["alpine:3.22"] + ); + // A bare global ARG keeps the frontend's value. + assert_eq!( + images( + "ARG BUILDPLATFORM\nARG TARGETPLATFORM\nFROM --platform=$BUILDPLATFORM alpine:3.22 AS a\nFROM --platform=${TARGETPLATFORM} a\n" + ), + ["alpine:3.22"] + ); +} + +#[test] +fn arg_names_must_be_plain_identifiers() { + // The frontend would unquote, unescape or expand these names, so a literal + // compare could miss an override of BUILDPLATFORM or TARGETPLATFORM. + for dockerfile in [ + "ARG \"BUILDPLATFORM\"=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG 'TARGETPLATFORM'=linux/amd64\nFROM --platform=$TARGETPLATFORM alpine:3.22\n", + "ARG BUILD\\PLATFORM=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG X=BUILDPLATFORM\nARG $X=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG ${X}\nFROM alpine:3.22\n", + "ARG 1V=1\nFROM alpine:3.22\n", + "ARG my-arg\nFROM alpine:3.22\n", + // Stage arguments too. + "FROM alpine:3.22\nARG \"V\"=1\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + assert_eq!( + images("ARG _V2=3.22 v_3\nFROM alpine:$_V2\nARG STAGE_ARG=1\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn expanded_argument_values_are_bounded() { + // 13 doublings make 8 KiB, past the 4 KiB value cap: unresolved, so a reference + // using it refuses as a variable the scanner cannot resolve. + let doubled = |times: usize, from: &str| { + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(times)); + dockerfile.push_str(from); + dockerfile + }; + assert_eq!( + code(&doubled(13, "FROM alpine:$A\n")), + "native_build_dockerfile" + ); + // Under the cap the value still resolves. + let scan = scan_with(&doubled(6, "FROM example/tool:$A\n"), None, &[]).unwrap(); + assert_eq!(names(&scan), [format!("example/tool:{}", "x".repeat(64))]); + // An overridden platform argument past the cap is unresolved and still refuses. + assert_eq!( + code(&doubled( + 13, + "ARG BUILDPLATFORM=$A\nFROM --platform=$BUILDPLATFORM alpine:3.22\n" + )), + "native_build_dockerfile" + ); +} + +#[test] +fn doubling_argument_defaults_stay_bounded() { + // Without a cap, 40 doublings would need a 1 TiB value. + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(40)); + dockerfile.push_str("FROM alpine:3.22\n"); + assert_eq!(images(&dockerfile), ["alpine:3.22"]); + // Distinct copies of a capped value share one 256 KiB budget: the first copies + // resolve (and fail as over-long references), later ones are unresolved. + let mut dockerfile = format!("ARG A={}\n", "x".repeat(4000)); + for index in 0..100 { + dockerfile.push_str(&format!("ARG B{index}=$A\n")); + } + let with = |from: &str| format!("{dockerfile}{from}"); + assert_eq!(code(&with("FROM alpine:$B0\n")), "native_build_base_image"); + assert_eq!(code(&with("FROM alpine:$B99\n")), "native_build_dockerfile"); + assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); +} + +#[test] +fn a_stage_may_not_be_named_scratch() { + // The frontend links `FROM scratch` to an earlier stage of that name, so the + // stage's image and platform would apply. + for dockerfile in [ + "FROM alpine:3.22 AS scratch\nFROM scratch\n", + "FROM --platform=linux/amd64 alpine:3.22 AS Scratch\nFROM scratch\n", + "FROM alpine:3.22 AS SCRATCH\nFROM busybox:1.37\nCOPY --from=scratch / /\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // Plain `scratch` is still the empty image. + assert!(images("FROM scratch\n").is_empty()); + assert_eq!( + images("FROM alpine:3.22 AS build\nFROM scratch\nCOPY --from=build / /\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn frontend_selection_directives_refuse() { + for dockerfile in [ + "# syntax=docker/dockerfile:1\nFROM alpine:3.22\n", + "#SYNTAX = example/frontend\nFROM alpine:3.22\n", + "#!/usr/bin/env buildctl\n# syntax=example/frontend\nFROM alpine:3.22\n", + "// syntax=example/frontend\nFROM alpine:3.22\n", + "# check=error=true\n# syntax=example/frontend\nFROM alpine:3.22\n", + "{\"syntax\": \"example/frontend\"}", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An ordinary comment that mentions syntax later in the file is fine. + assert_eq!( + images("FROM alpine:3.22\n# syntax=example/frontend\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn escape_directive_changes_continuations() { + let dockerfile = "# escape=`\nFROM alpine:3.22 `\n AS base\nRUN echo C:\\path\nFROM base\n"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + assert_eq!( + code("# escape=x\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("# escape=`\n# escape=\\\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + // A directive after an instruction or blank line is only a comment. + assert_eq!( + images("\n# escape=`\nFROM alpine:3.22 \\\n AS base\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn continuations_skip_comments_and_blank_lines() { + let dockerfile = "\ +FROM \\ +# a comment inside the instruction +\x20\x20\x20 + alpine:3.22 \\ + AS base\r +COPY \\ + --from=example/assets:7 \\ + /a /a +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22", "example/assets:7"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); +} + +#[test] +fn heredoc_bodies_are_not_instructions() { + let dockerfile = "\ +FROM alpine:3.22 +RUN < /x && <<\"MORE\" cat > /y +\tFROM example/still-body:1 +\tDONE +FROM example/also-body:2 +MORE +COPY < /x\nFROM scratch\nEOF\n", + "FROM alpine:3.22\nCOPY << 'FILE' /etc/x\nFROM scratch\nFILE\n", + "FROM alpine:3.22\nRUN true <>() + .join("\n"); + assert_eq!(images(&within).len(), MAX_IMAGE_REFERENCES); + + let stages: String = (0..=MAX_STAGES).map(|_| "FROM alpine:3.22\n").collect(); + assert_eq!(code(&stages), "native_build_dockerfile"); +} + +#[test] +fn reserved_build_arguments_refuse_before_reading() { + for name in [ + "BUILDKIT_SYNTAX", + "BUILDKIT_INLINE_CACHE", + "TARGETARCH", + "BUILDPLATFORM", + "TARGETSTAGE", + ] { + let error = scan_with("FROM alpine:3.22\n", None, &[(name, "x")]).unwrap_err(); + assert_eq!(error.code, "native_build_argument", "{name}"); + } +} + +#[test] +fn messages_never_echo_dockerfile_text_or_argument_values() { + let secret = "do-not-echo-7f3a"; + for (dockerfile, args) in [ + (format!("FROM ghcr.io/{secret}/app:1\n"), vec![]), + (format!("ARG V={secret}\nFROM alpine:${{V:-x}}\n"), vec![]), + ( + "ARG V\nFROM ghcr.io/$V:1\n".to_string(), + vec![("V", secret)], + ), + (format!("FROM alpine:3.22\n{secret} instruction\n"), vec![]), + ( + format!("FROM alpine:3.22\nCOPY --from=${secret} / /\n"), + vec![], + ), + ] { + let error = scan_with(&dockerfile, None, &args).unwrap_err(); + assert!(!error.message.contains(secret), "{}", error.message); + assert!(!error.message.contains("ghcr"), "{}", error.message); + } +} diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..3348370ba 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -3,6 +3,8 @@ pub mod admission; mod agent; mod artifact; mod bridge; +pub mod build_policy; +pub mod build_scan; mod config_audit; mod dependency_socket; pub use bridge::BridgeIntent; @@ -143,6 +145,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 +168,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 +198,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;