diff --git a/.github/workflows/composable-python-layer-smoke.yml b/.github/workflows/composable-python-layer-smoke.yml index 7496600103..56b57ed08f 100644 --- a/.github/workflows/composable-python-layer-smoke.yml +++ b/.github/workflows/composable-python-layer-smoke.yml @@ -7,11 +7,17 @@ on: - scripts/build-python-runtime-layer.py - scripts/test_build_python_runtime_layer.py - crates/mvm-build/src/runtime_layer.rs + - crates/mvm-build/src/pr_test_synthetic_base.rs + - crates/mvm-cli/src/commands/vm/exec.rs - crates/mvm-cli/src/commands/vm/exec/** - crates/mvm-hostd/src/plan_admission/composition.rs - crates/mvm-contract/src/plan/bundle.rs - crates/mvm-core/src/registry_pack/runtime_layer.rs - crates/mvm-core/examples/verify-runtime-layer-fixture.rs + - crates/mvm-build/src/guest_runtime/** + - crates/mvm-build/src/guest_runtime.rs + - crates/mvm-client/src/portable_boot_assets.rs + - crates/mvm-runtime/src/runtime_evidence.rs workflow_dispatch: permissions: @@ -19,6 +25,265 @@ permissions: id-token: write jobs: + # Uses the ordinary unsigned guest builder, not a release signer. Signing + # happens only in the PR job below and nothing is published as a CLI release. + pr-guest-bins: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/guest-bins.yml + permissions: + contents: read + + pr-cli-bins: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - name: Build unpublished fixture-only CLI and host helpers + env: + MVM_EMBED: "0" + # This job only builds for the hosted runner. The toolchain action + # installed 1.97.1; avoid a second toolchain-file component install. + RUSTUP_TOOLCHAIN: "1.97.1" + run: | + set -euo pipefail + cargo build --locked --profile release-witness --bin mvmctl \ + --features user,pr-test-guest-runtime,mvm-cli/pr-test-synthetic-base + cargo build --locked --profile release-witness -p mvm-hostd --features pr-test-guest-runtime \ + --bin mvm-host-agent --bin mvm-signer-helper --bin mvm-network-endpoint + for binary in mvmctl mvm-host-agent mvm-signer-helper mvm-network-endpoint; do + test -x "target/release-witness/$binary" + done + target/release-witness/mvmctl --version + - name: Retain unpublished CLI for an authorized KVM witness + uses: actions/upload-artifact@v7 + with: + name: pr-test-cli-${{ github.run_id }}-${{ github.run_attempt }} + path: | + target/release-witness/mvmctl + target/release-witness/mvm-host-agent + target/release-witness/mvm-signer-helper + target/release-witness/mvm-network-endpoint + if-no-files-found: error + retention-days: 1 + + signed-pr-guest-runtime: + needs: pr-guest-bins + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: sigstore/cosign-installer@v3 + - uses: actions/download-artifact@v8 + with: + name: mvm-guest-bins-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/pr-runtime + - name: Sign and verify PR-only guest runtime inputs + env: + MVM_PR_TEST_RUNTIME_DIR: ${{ runner.temp }}/pr-runtime + MVM_PR_TEST_PULL_REQUEST: ${{ github.event.pull_request.number }} + RUSTUP_TOOLCHAIN: "1.97.1" + run: | + set -euo pipefail + version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["package"]["version"])')" + archive="$MVM_PR_TEST_RUNTIME_DIR/mvm-guest-bins-v${version}.tar.gz" + test -f "$archive" && test ! -L "$archive" + test -f "${archive}.sha256" && test ! -L "${archive}.sha256" + (cd "$MVM_PR_TEST_RUNTIME_DIR" && sha256sum --check "$(basename "${archive}.sha256")") + for blob in "$archive" "${archive}.sha256"; do + cosign sign-blob --yes --new-bundle-format --bundle "${blob}.bundle" "$blob" + done + cargo test --locked -p mvm-build --features pr-test-guest-runtime --lib \ + guest_runtime::evidence::tests::signed_pr_runtime_authenticates_exact_identity_and_refuses_tampering \ + -- --ignored --exact + cargo test --locked -p mvm-client --features pr-test-guest-runtime --lib \ + portable_boot_assets::tests::pr_test_local_acquisition_checks_assembled_runtime_independently \ + -- --ignored --exact + - name: Retain synthetic PR quartet for a builder-VM CLI witness + uses: actions/upload-artifact@v7 + with: + name: pr-test-guest-runtime-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pr-runtime/mvm-guest-bins-v*.tar.gz* + if-no-files-found: error + retention-days: 1 + + signed-pr-base-input: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: sigstore/cosign-installer@v3 + - name: Authenticate only the PR's nonbootable synthetic base input + env: + MVM_EMBED: "0" + MVM_PR_TEST_BASE_DIR: ${{ runner.temp }}/pr-base + MVM_PR_TEST_PULL_REQUEST: ${{ github.event.pull_request.number }} + RUSTUP_TOOLCHAIN: "1.97.1" + run: | + set -euo pipefail + cargo test --locked -p mvm-build --features pr-test-synthetic-base --lib \ + pr_test_synthetic_base::tests::write_synthetic_pr_base_fixture_for_ci \ + -- --ignored --exact + cosign sign-blob --yes --new-bundle-format \ + --bundle "$MVM_PR_TEST_BASE_DIR/image-set.bundle" \ + "$MVM_PR_TEST_BASE_DIR/image-set.json" + cargo test --locked -p mvm-build --features pr-test-synthetic-base --lib \ + pr_test_synthetic_base::tests::real_pr_signature_accepts_only_unchanged_exact_identity \ + -- --ignored --exact + + signed-pr-bootable-base: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + artifact_id: 13746141841 + artifact_size: 503284622 + artifact_digest: d12a6026d2858f1414f33a7692abf8693a4b158f547abe323fe19170eb301bfd + - arch: aarch64 + artifact_id: 13745508933 + artifact_size: 498828250 + artifact_digest: fb0458726a70c67fc72e3948d4f9a7801549ab1d8002af0a398f84bbd9db9c5e + permissions: + contents: read + actions: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + # The emitter records this exact clean source checkout; it never builds images. + - name: Select reviewed test-only Linux source + id: test-images-ref + run: echo "ref=$(cat tests/fixtures/composable-images-source.ref)" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6 + with: + repository: tinylabscom/mvm-images + ref: ${{ steps.test-images-ref.outputs.ref }} + path: mvm-images + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: sigstore/cosign-installer@v3 + - name: Authenticate prebuilt Linux input and emit a local test set + env: + GH_TOKEN: ${{ github.token }} + MVM_PR_TEST_BASE_DIR: ${{ runner.temp }}/pr-bootable-base + MVM_PR_TEST_SOURCE_DIR: ${{ runner.temp }}/pr-linux-source + MVM_PR_TEST_ZIP: ${{ runner.temp }}/linux-layer-${{ matrix.arch }}.zip + run: | + set -euo pipefail + source_sha=$(cat tests/fixtures/composable-images-source.ref) + arch=${{ matrix.arch }} + artifact_id=${{ matrix.artifact_id }} + artifact_size=${{ matrix.artifact_size }} + artifact_digest=${{ matrix.artifact_digest }} + test "$(git -C mvm-images rev-parse HEAD)" = "$source_sha" + gh api repos/tinylabscom/mvm-images/actions/runs/42778535622 | + jq -e --arg sha "$source_sha" ' + .id == 42778535622 and .repository.full_name == "tinylabscom/mvm-images" and + .head_repository.full_name == "tinylabscom/mvm-images" and + .event == "pull_request" and + (.pull_requests | length == 1 and .[0].number == 78) and + .path == ".github/workflows/build.yml" and .run_attempt == 1 and + .head_sha == $sha and .status == "completed" and .conclusion == "success" + ' >/dev/null + gh api "repos/tinylabscom/mvm-images/actions/artifacts/$artifact_id" | + jq -e --arg sha "$source_sha" --arg digest "sha256:$artifact_digest" \ + --arg arch "$arch" --argjson id "$artifact_id" --argjson size "$artifact_size" ' + .id == $id and .name == ("linux-layer-" + $arch) and + .size_in_bytes == $size and .digest == $digest and .expired == false and + .workflow_run.id == 42778535622 and .workflow_run.head_sha == $sha and + .workflow_run.repository_id == 1376194514 and + .workflow_run.head_repository_id == 1376194514 + ' >/dev/null + gh api "repos/tinylabscom/mvm-images/actions/artifacts/$artifact_id/zip" > "$MVM_PR_TEST_ZIP" + printf '%s %s\n' "$artifact_digest" "$MVM_PR_TEST_ZIP" | sha256sum --check + mkdir -p "$MVM_PR_TEST_SOURCE_DIR" + unzip -j "$MVM_PR_TEST_ZIP" \ + "default-microvm-vmlinux-$arch" \ + "default-microvm-rootfs-$arch.ext4" \ + "default-microvm-rootfs-$arch.tar.gz" \ + -d "$MVM_PR_TEST_SOURCE_DIR" + for name in "default-microvm-vmlinux-$arch" \ + "default-microvm-rootfs-$arch.ext4" "default-microvm-rootfs-$arch.tar.gz"; do + test -s "$MVM_PR_TEST_SOURCE_DIR/$name" && test ! -L "$MVM_PR_TEST_SOURCE_DIR/$name" + sha256sum "$MVM_PR_TEST_SOURCE_DIR/$name" + done + case "$arch" in + x86_64) + readelf -h "$MVM_PR_TEST_SOURCE_DIR/default-microvm-vmlinux-$arch" | + grep -q 'Machine:.*Advanced Micro Devices X86-64' + kernel_format=kernel:elf + ;; + aarch64) + # The arm64 loader consumes Image, not ELF; Linux's ARMd + # magic at byte 56 identifies the selected boot bytes. + magic=$(od -An -c -j56 -N4 "$MVM_PR_TEST_SOURCE_DIR/default-microvm-vmlinux-$arch" | + tr -d ' \n') + test "$magic" = ARMd + kernel_format=kernel:image + ;; + esac + python3 -m venv "$RUNNER_TEMP/local-manifest-venv" + PURE=1 "$RUNNER_TEMP/local-manifest-venv/bin/pip" install \ + -r mvm-images/scripts/requirements-local-manifest.txt + "$RUNNER_TEMP/local-manifest-venv/bin/python" mvm-images/scripts/emit-local-manifest.py \ + --images-checkout mvm-images --arch "$arch" --builder-cache-contract 5 \ + --out "$MVM_PR_TEST_BASE_DIR" \ + --artifact default_tenant_workload_kernel "$kernel_format" \ + "$MVM_PR_TEST_SOURCE_DIR/default-microvm-vmlinux-$arch" \ + --capability default_tenant_workload_kernel virtio_vsock \ + --artifact default_tenant_workload_rootfs ext4 \ + "$MVM_PR_TEST_SOURCE_DIR/default-microvm-rootfs-$arch.ext4" \ + --artifact default_tenant_workload_rootfs tar_gz \ + "$MVM_PR_TEST_SOURCE_DIR/default-microvm-rootfs-$arch.tar.gz" \ + --capability default_tenant_workload_rootfs virtio_blk + - name: Sign and verify the bootable PR-only base + env: + MVM_EMBED: "0" + MVM_PR_TEST_BASE_DIR: ${{ runner.temp }}/pr-bootable-base + MVM_PR_TEST_PULL_REQUEST: ${{ github.event.pull_request.number }} + MVM_PR_TEST_ARCH: ${{ matrix.arch }} + RUSTUP_TOOLCHAIN: "1.97.1" + run: | + set -euo pipefail + cosign sign-blob --yes --new-bundle-format \ + --bundle "$MVM_PR_TEST_BASE_DIR/image-set.bundle" \ + "$MVM_PR_TEST_BASE_DIR/image-set.json" + cargo test --locked -p mvm-build --features pr-test-synthetic-base --lib \ + pr_test_synthetic_base::tests::real_pr_signature_accepts_only_unchanged_exact_identity \ + -- --ignored --exact + - name: Retain the signed PR base for a builder-VM composition witness + uses: actions/upload-artifact@v7 + with: + name: pr-test-bootable-base-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pr-bootable-base/ + if-no-files-found: error + retention-days: 1 + signed-input: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 2b3af67ad4..7f78895995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -580,6 +580,7 @@ template-registry-s3 = [ # in-process) needs it turned on. `mvm-runtime` is named directly too since # this crate depends on it straight, not only through `mvm-cli`. test-support = ["mvm-cli/test-support", "mvm-runtime/test-support"] +pr-test-guest-runtime = ["mvm-cli/pr-test-guest-runtime"] # Debug-only Apple Silicon warm-restore validation. The runtime additionally # requires a debug build on macOS/aarch64 before advertising the capability. hvf-live-validation = [ diff --git a/crates/mvm-agentd/src/bin/mvm-guest-agent/init.rs b/crates/mvm-agentd/src/bin/mvm-guest-agent/init.rs index fff76bcc79..cc1941f0e4 100644 --- a/crates/mvm-agentd/src/bin/mvm-guest-agent/init.rs +++ b/crates/mvm-agentd/src/bin/mvm-guest-agent/init.rs @@ -184,6 +184,11 @@ pub(crate) fn apply_activation( // The telemetry signing key is the egress client's, mode 0400; read it now // or the listener can never answer a session. crate::telemetry::capture_session_keys_before_privilege_drop(); + // The guest console is root-owned. Retain only a write-only descriptor + // before dropping to the workload identity; detached children receive a + // duplicate for output, never the agent's inherited read/write stdio. + #[cfg(target_os = "linux")] + crate::interactive::retain_detached_console_before_drop(); guest_mount::drop_guest_agent_privilege(guest_mount::WORKLOAD_UID, guest_mount::WORKLOAD_GID)?; boot_state.set_activation(ActivationState::Activated); diff --git a/crates/mvm-agentd/src/bin/mvm-guest-agent/interactive.rs b/crates/mvm-agentd/src/bin/mvm-guest-agent/interactive.rs index 6760b79077..efc31dbe97 100644 --- a/crates/mvm-agentd/src/bin/mvm-guest-agent/interactive.rs +++ b/crates/mvm-agentd/src/bin/mvm-guest-agent/interactive.rs @@ -5,6 +5,7 @@ use mvm_agentd::vsock::GuestResponse; use std::io::Write; +use std::sync::OnceLock; use crate::HandlerCtx; use crate::socket::write_response; @@ -54,36 +55,103 @@ fn do_exec_streaming( /// every other helper it comes from the runtime overlay: neither mkGuest nor /// OCI injection bakes a copy into the image. const EXIT_REPORT_OVERLAY: &str = "/mvm/runtime/exit-report"; +const DETACHED_CONSOLE: &str = "/dev/console"; +static HELD_DETACHED_CONSOLE: OnceLock = OnceLock::new(); + +/// Retain a write-only console before PID 1 drops the guest agent to UID 901. +/// +/// A detached workload may write its output, but cannot inherit a root-opened +/// read/write console descriptor or acquire device access after the drop. +#[cfg(target_os = "linux")] +pub(crate) fn retain_detached_console_before_drop() { + match open_detached_console(std::path::Path::new(DETACHED_CONSOLE)) { + Ok(file) => { + let _ = HELD_DETACHED_CONSOLE.set(file); + } + Err(error) => { + eprintln!("mvm-guest-agent: detached console unavailable: {error}"); + } + } +} + +fn open_detached_console(path: &std::path::Path) -> std::io::Result { + std::fs::OpenOptions::new().write(true).open(path) +} + +fn detached_console_handles() -> std::io::Result<(std::fs::File, std::fs::File)> { + let stdout = match HELD_DETACHED_CONSOLE.get() { + Some(held) => held.try_clone()?, + None => open_detached_console(std::path::Path::new(DETACHED_CONSOLE))?, + }; + let stderr = stdout.try_clone()?; + Ok((stdout, stderr)) +} /// Spawn `argv` as a detached workload and return its ack (DevOnly). /// /// Models the image `/init` entrypoint launch, but agent-driven and /// non-blocking: the child gets its own session (`setsid`), stdin from -/// `/dev/null`, and stdout/stderr on `/dev/console` (which the host +/// `/dev/null`, and stdout/stderr on write-only `/dev/console` handles (which the host /// backend captures to `console.log`). The call returns immediately with /// `DetachedStarted { pid }`; a detached reaper thread waits on the child /// and reports its exit code to the host's workload-exit port through the /// runtime overlay's exit reporter, so the VM powers off when the workload finishes /// (docker `-d` semantics). The reaper never blocks the agent request loop. fn do_run_detached(argv: Vec, env: Vec<(String, String)>) -> GuestResponse { + // A named local pack dispatches the sealed marker itself. Unlike an + // arbitrary Dev command, this path must not bypass the entrypoint's + // ownership, mode and filesystem checks just because it is detached. + if let Err(message) = validate_sealed_detached_marker( + &argv, + &mvm_agentd::entrypoint::EntrypointPolicy::sealed_script_marker(), + ) { + return GuestResponse::Error { message }; + } + let (stdout, stderr) = match detached_console_handles() { + Ok(handles) => handles, + Err(error) => { + return GuestResponse::Error { + message: format!("run-detached: open write-only console: {error}"), + }; + } + }; do_run_detached_with( argv, crate::handlers::overlay_env(env), - std::path::Path::new("/dev/console"), + stdout, + stderr, std::path::Path::new(EXIT_REPORT_OVERLAY), ) } -/// Testable core of [`do_run_detached`]. `console` receives the workload's -/// stdout/stderr (production: `/dev/console`, captured by the backend to -/// `console.log`); `exit_report_bin` is the reporter the reaper execs with the -/// workload's exit code (production: the overlay's `/mvm/runtime/exit-report`). A test -/// points both at a tempdir to observe the spawn, the console redirect, and the -/// reported exit code without a live guest. +fn validate_sealed_detached_marker( + argv: &[String], + policy: &mvm_agentd::entrypoint::EntrypointPolicy, +) -> Result<(), String> { + if argv + .first() + .is_some_and(|arg| std::path::Path::new(arg) == policy.marker_path) + { + if argv.len() != 1 { + return Err("sealed detached entrypoint cannot take ad-hoc arguments".to_string()); + } + policy + .validate() + .map_err(|error| format!("sealed detached entrypoint invalid: {error}"))?; + } + Ok(()) +} + +/// Testable core of [`do_run_detached`]. The write-only console handles receive +/// stdout/stderr (captured by the backend in `console.log`). `exit_report_bin` +/// reports the workload's exit code (production: the overlay's +/// `/mvm/runtime/exit-report`). A test uses a temporary file to observe the +/// spawn, console redirect and reported exit code without a live guest. fn do_run_detached_with( argv: Vec, env: Vec<(String, String)>, - console: &std::path::Path, + console_out: std::fs::File, + console_err: std::fs::File, exit_report_bin: &std::path::Path, ) -> GuestResponse { use std::os::unix::process::CommandExt; @@ -103,25 +171,6 @@ fn do_run_detached_with( }; } }; - // Two independent write handles to the console so stdout and stderr - // each own their fd (no shared-offset surprises). - let console_out = match std::fs::OpenOptions::new().write(true).open(console) { - Ok(f) => f, - Err(e) => { - return GuestResponse::Error { - message: format!("run-detached: open console {}: {e}", console.display()), - }; - } - }; - let console_err = match std::fs::OpenOptions::new().write(true).open(console) { - Ok(f) => f, - Err(e) => { - return GuestResponse::Error { - message: format!("run-detached: open console {}: {e}", console.display()), - }; - } - }; - // env_clear(), then a minimal safe base plus the caller's vars. // Drop malformed entries rather than let `Command::env` panic // (empty key, key containing '=' or NUL, value containing NUL). @@ -564,9 +613,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let console = dir.path().join("console"); let reported = dir.path().join("reported-code"); - // The handler opens the console for writing (it exists in production as - // `/dev/console`); pre-create the stand-in so the open succeeds. + // Production retains a write-only descriptor before UID 901. The test + // opens two write-only handles to the stand-in console. std::fs::File::create(&console).unwrap(); + let stdout = open_detached_console(&console).unwrap(); + let stderr = open_detached_console(&console).unwrap(); // Stand-in for `mvm-exit-report`: records its exit-code argument so the // reaper's report is observable. @@ -585,7 +636,8 @@ mod tests { "printf RAN-DETACHED; exit 7".into(), ], vec![], - &console, + stdout, + stderr, &reporter, ); let pid = match resp { @@ -628,10 +680,13 @@ mod tests { #[test] fn run_detached_refuses_empty_argv() { + let stdout = open_detached_console(std::path::Path::new("/dev/null")).unwrap(); + let stderr = open_detached_console(std::path::Path::new("/dev/null")).unwrap(); match do_run_detached_with( vec![], vec![], - std::path::Path::new("/dev/null"), + stdout, + stderr, std::path::Path::new("/bin/true"), ) { GuestResponse::Error { message } => { @@ -641,6 +696,54 @@ mod tests { } } + #[test] + fn retained_console_is_write_only_even_when_its_device_is_readable() { + use std::io::Read as _; + + let directory = tempfile::tempdir().expect("console directory"); + let path = directory.path().join("console"); + std::fs::write(&path, b"private console input").expect("readable device stand-in"); + let mut output = open_detached_console(&path).expect("open only for writing"); + let error = output + .read(&mut [0_u8; 1]) + .expect_err("workload output must not become a console input channel"); + assert_eq!(error.raw_os_error(), Some(libc::EBADF)); + } + + #[test] + fn sealed_detached_marker_requires_an_immutable_executable_without_argv_override() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let root = tempfile::tempdir().expect("marker root"); + let marker_dir = root.path().join("etc/mvm"); + std::fs::create_dir_all(&marker_dir).expect("marker directory"); + let marker = marker_dir.join("entrypoint"); + std::fs::write(&marker, "#!/bin/sh\nexit 0\n").expect("sealed script"); + std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o555)) + .expect("sealed mode"); + let metadata = std::fs::metadata(&marker).expect("script owner"); + let mut policy = mvm_agentd::entrypoint::EntrypointPolicy::sealed_script_marker(); + policy.marker_path = marker.clone(); + policy.allowed_prefix = marker_dir.clone(); + policy.same_fs_as = Some(marker_dir); + policy.required_uid = metadata.uid(); + policy.required_gid = metadata.gid(); + let argv = vec![marker.to_string_lossy().into_owned()]; + + validate_sealed_detached_marker(&argv, &policy).expect("sealed marker"); + assert!( + validate_sealed_detached_marker(&[argv[0].clone(), "override".into()], &policy) + .is_err(), + "the sealed entrypoint does not accept an argv override" + ); + std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o755)) + .expect("writable mode"); + assert!( + validate_sealed_detached_marker(&argv, &policy).is_err(), + "writable entrypoint cannot be launched detached" + ); + } + #[cfg(target_os = "linux")] #[test] fn run_detached_drops_inherited_agent_descriptor() { @@ -661,6 +764,8 @@ mod tests { let console = dir.path().join("console"); std::fs::File::create(&console).expect("console stand-in"); + let stdout = open_detached_console(&console).expect("stdout handle"); + let stderr = open_detached_console(&console).expect("stderr handle"); let reporter = dir.path().join("exit-report"); std::fs::write(&reporter, "#!/bin/sh\nexit 0\n").expect("reporter script"); use std::os::unix::fs::PermissionsExt as _; @@ -674,7 +779,8 @@ mod tests { "if [ -e /proc/self/fd/20 ]; then echo present; else echo closed; fi".into(), ], Vec::new(), - &console, + stdout, + stderr, &reporter, ); assert!(matches!(response, GuestResponse::DetachedStarted { .. })); diff --git a/crates/mvm-build/Cargo.toml b/crates/mvm-build/Cargo.toml index 89d8a3c061..f00ddc95de 100644 --- a/crates/mvm-build/Cargo.toml +++ b/crates/mvm-build/Cargo.toml @@ -141,6 +141,10 @@ release-channel = [] # `builder_pack`'s tests can exercise `verify_pack_keyless_at` without # every mvm-build build pulling the sigstore dependency tree. manifest-verify = ["mvm-core/manifest-verify"] +# PR-only signed runtime inputs; never part of the release channel. +pr-test-guest-runtime = ["manifest-verify", "mvm-core/pr-test-guest-runtime"] +# Authenticate a synthetic PR base only as a local development input. +pr-test-synthetic-base = ["manifest-verify"] # Exposes `published_image_set::fixture`, a signed-root-shaped image set served # from a directory, to other crates' tests. test-support = ["mvm-fs/test-support"] diff --git a/crates/mvm-build/src/guest_runtime.rs b/crates/mvm-build/src/guest_runtime.rs index ec9bbee567..bcbf24adf2 100644 --- a/crates/mvm-build/src/guest_runtime.rs +++ b/crates/mvm-build/src/guest_runtime.rs @@ -182,11 +182,39 @@ pub fn install_signed_guest_runtime_archive( arch: GuestArch, ) -> Result { let verified = evidence::verify_release_archive(archive, checksum, bundle, version, arch)?; + let mut runtime = install_verified_archive(cache_root, &verified, arch)?; + runtime.release_proof = Some(evidence::RuntimeReleaseProof { + archive: archive.canonicalize()?, + checksum: checksum.canonicalize()?, + signature_bundle: bundle.canonicalize()?, + archive_sha256: verified.archive_sha256().to_string(), + version: version.to_string(), + arch, + }); + Ok(runtime) +} + +/// Install a PR-signed test archive; never mint release provenance. +#[cfg(feature = "pr-test-guest-runtime")] +pub fn install_pr_test_guest_runtime_archive( + cache_root: &Path, + proof: &mvm_core::protocol::runtime_release::PrTestRuntimeProof, +) -> Result { + let verified = + evidence::verify_pr_test_runtime_proof(proof, &proof.input.version, proof.input.arch)?; + install_verified_archive(cache_root, &verified, proof.input.arch) +} + +fn install_verified_archive( + cache_root: &Path, + verified: &evidence::VerifiedRuntimeManifest, + arch: GuestArch, +) -> Result { let base = cache_root.join("guest-runtime").join("v1"); fs::create_dir_all(&base)?; let _lock = guest_agent_build::acquire_guest_build_lock(&base, "guest runtime")?; let stage = stage_archive(&base, verified.archive_path())?; - let mut runtime = promote_staged_archive( + let runtime = promote_staged_archive( &base, stage, verified.manifest().clone(), @@ -194,14 +222,6 @@ pub fn install_signed_guest_runtime_archive( arch, ArchiveScope::Release, )?; - runtime.release_proof = Some(evidence::RuntimeReleaseProof { - archive: archive.canonicalize()?, - checksum: checksum.canonicalize()?, - signature_bundle: bundle.canonicalize()?, - archive_sha256: verified.archive_sha256().to_string(), - version: version.to_string(), - arch, - }); Ok(runtime) } diff --git a/crates/mvm-build/src/guest_runtime/evidence.rs b/crates/mvm-build/src/guest_runtime/evidence.rs index b27da5b646..01c564ffb4 100644 --- a/crates/mvm-build/src/guest_runtime/evidence.rs +++ b/crates/mvm-build/src/guest_runtime/evidence.rs @@ -349,12 +349,73 @@ pub fn verify_runtime_release_proof( Ok(verified) } +/// Authenticate PR-only runtime bytes, without representing them as a release. +#[cfg(feature = "pr-test-guest-runtime")] +pub fn verify_pr_test_runtime_proof( + proof: &mvm_core::protocol::runtime_release::PrTestRuntimeProof, + expected_version: &str, + expected_arch: GuestArch, +) -> Result { + if !crate::artifact_acquisition::compiled_channel().permits_automatic_builds() + || proof.pull_request == 0 + || proof.input.version != expected_version + || proof.input.arch != expected_arch + || !super::is_archive_digest(&proof.input.archive_sha256) + { + return Err(GuestRuntimeError::Cache( + "PR test runtime selection does not match this CLI".into(), + )); + } + let identity = format!( + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/{}/merge", + proof.pull_request + ); + let verified = verify_archive_with_identities( + &proof.input.archive, + &proof.input.checksum, + &proof.input.signature_bundle, + expected_version, + expected_arch, + &[identity], + true, + )?; + if verified.archive_sha256() != proof.input.archive_sha256 { + return Err(GuestRuntimeError::Cache( + "PR test runtime archive digest changed".into(), + )); + } + verified + .declaration(expected_arch)? + .validate(expected_arch)?; + Ok(verified) +} + pub(super) fn verify_release_archive( archive: &Path, checksum: &Path, bundle: &Path, version: &str, arch: GuestArch, +) -> Result { + verify_archive_with_identities( + archive, + checksum, + bundle, + version, + arch, + &mvm_core::release_trust::accepted_release_identities(version), + false, + ) +} + +fn verify_archive_with_identities( + archive: &Path, + checksum: &Path, + bundle: &Path, + version: &str, + arch: GuestArch, + identities: &[String], + require_checksum_signature: bool, ) -> Result { let asset = crate::guest_bins::guest_bins_archive_name(version); let bytes = std::fs::read(archive)?; @@ -366,8 +427,17 @@ pub(super) fn verify_release_archive( // still verify; portable acquisition separately requires that fourth file. let checksum_signature = if checksum_bundle_present(&checksum_bundle)? { let signature = std::fs::read(&checksum_bundle)?; - verify_checksum_signature_bytes(&checksum_bytes, &signature, version)?; + verify_checksum_signature_bytes_for_identities( + &checksum_bytes, + &signature, + version, + identities, + )?; Some(signature) + } else if require_checksum_signature { + return Err(GuestRuntimeError::Cache( + "PR test runtime checksum signature is missing".into(), + )); } else { None }; @@ -385,7 +455,7 @@ pub(super) fn verify_release_archive( &bytes, &signature, &asset, - &mvm_core::release_trust::accepted_release_identities(version), + identities, mvm_core::release_trust::RELEASE_OIDC_ISSUER, )?; let mut snapshot = tempfile::NamedTempFile::new()?; @@ -434,6 +504,20 @@ fn verify_checksum_signature_bytes( checksum_bytes: &[u8], bundle: &[u8], version: &str, +) -> Result<(), GuestRuntimeError> { + verify_checksum_signature_bytes_for_identities( + checksum_bytes, + bundle, + version, + &mvm_core::release_trust::accepted_release_identities(version), + ) +} + +fn verify_checksum_signature_bytes_for_identities( + checksum_bytes: &[u8], + bundle: &[u8], + version: &str, + identities: &[String], ) -> Result<(), GuestRuntimeError> { crate::release_signature::verify_release_archive_bytes( checksum_bytes, @@ -442,7 +526,7 @@ fn verify_checksum_signature_bytes( "{}.sha256", crate::guest_bins::guest_bins_archive_name(version) ), - &mvm_core::release_trust::accepted_release_identities(version), + identities, mvm_core::release_trust::RELEASE_OIDC_ISSUER, )?; Ok(()) @@ -622,6 +706,131 @@ mod tests { (dir, verified) } + #[cfg(all(feature = "pr-test-guest-runtime", not(feature = "release-channel")))] + #[test] + #[ignore = "requires a real keyless-signed archive and checksum from the PR smoke workflow"] + fn signed_pr_runtime_authenticates_exact_identity_and_refuses_tampering() { + use mvm_core::protocol::runtime_release::PrTestRuntimeProof; + let directory = PathBuf::from( + std::env::var_os("MVM_PR_TEST_RUNTIME_DIR").expect("PR smoke fixture directory"), + ); + let pull_request: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .expect("PR smoke fixture number") + .parse() + .expect("numeric PR number"); + let version = env!("CARGO_PKG_VERSION"); + let arch = GuestArch::X86_64; + let asset = crate::guest_bins::guest_bins_archive_name(version); + let archive = directory.join(&asset); + let input = RuntimeReleaseProof { + archive: archive.clone(), + checksum: directory.join(format!("{asset}.sha256")), + signature_bundle: directory.join(format!("{asset}.bundle")), + archive_sha256: hex::encode(Sha256::digest(std::fs::read(&archive).unwrap())), + version: version.into(), + arch, + }; + let mut proof = PrTestRuntimeProof { + input, + pull_request, + }; + let verified = verify_pr_test_runtime_proof(&proof, version, arch) + .expect("exact GitHub PR workflow identity signs both archive and checksum"); + assert_eq!(verified.archive_sha256(), proof.input.archive_sha256); + assert!( + verify_runtime_release_proof(&proof.input, version, arch).is_err(), + "PR workflow signature never impersonates release identity" + ); + proof.pull_request += 1; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch).is_err(), + "another PR identity cannot authorize the archive" + ); + proof.pull_request -= 1; + let copy = tempfile::tempdir().unwrap(); + for (source, name) in [ + (&proof.input.archive, asset.as_str()), + (&proof.input.checksum, "checksum"), + (&proof.input.signature_bundle, "archive.bundle"), + ( + &checksum_bundle_path(&proof.input.checksum), + "checksum.bundle", + ), + ] { + std::fs::copy(source, copy.path().join(name)).unwrap(); + } + proof.input.archive = copy.path().join(&asset); + proof.input.checksum = copy.path().join("checksum"); + proof.input.signature_bundle = copy.path().join("archive.bundle"); + std::fs::write(&proof.input.archive, b"tampered").unwrap(); + assert!(verify_pr_test_runtime_proof(&proof, version, arch).is_err()); + std::fs::copy(&archive, &proof.input.archive).unwrap(); + std::fs::write(&proof.input.checksum, b"tampered").unwrap(); + assert!(verify_pr_test_runtime_proof(&proof, version, arch).is_err()); + } + + #[cfg(all(feature = "pr-test-guest-runtime", not(feature = "release-channel")))] + #[test] + fn pr_test_runtime_requires_nonzero_pr_and_signed_checksum() { + use mvm_core::protocol::runtime_release::PrTestRuntimeProof; + let directory = tempfile::tempdir().unwrap(); + let version = "1.2.3"; + let arch = GuestArch::X86_64; + let asset = crate::guest_bins::guest_bins_archive_name(version); + let archive = directory.path().join(&asset); + let checksum = directory.path().join(format!("{asset}.sha256")); + std::fs::write(&archive, b"unsigned archive").unwrap(); + let digest = hex::encode(Sha256::digest(b"unsigned archive")); + std::fs::write(&checksum, format!("{digest} {asset}\n")).unwrap(); + let mut proof = PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive, + checksum, + signature_bundle: directory.path().join(format!("{asset}.bundle")), + archive_sha256: digest, + version: version.into(), + arch, + }, + pull_request: 0, + }; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch) + .unwrap_err() + .to_string() + .contains("selection") + ); + proof.pull_request = 42; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch) + .unwrap_err() + .to_string() + .contains("checksum signature") + ); + } + + #[cfg(all(feature = "pr-test-guest-runtime", feature = "release-channel"))] + #[test] + fn release_channel_refuses_pr_runtime_without_reading_input() { + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: "/missing/pr/archive".into(), + checksum: "/missing/pr/checksum".into(), + signature_bundle: "/missing/pr/bundle".into(), + archive_sha256: "a".repeat(64), + version: "1.2.3".into(), + arch: GuestArch::host(), + }, + pull_request: 42, + }; + assert!( + verify_pr_test_runtime_proof(&proof, "1.2.3", GuestArch::host()) + .unwrap_err() + .to_string() + .contains("PR test runtime selection"), + "a release-channel build cannot opt into PR-test runtime evidence" + ); + } + fn selection<'a>(paths: &'a [PathBuf; 3], root: &'a str) -> RuntimeArtifactSelection<'a> { RuntimeArtifactSelection { arch: GuestArch::X86_64, diff --git a/crates/mvm-build/src/image_source/build.rs b/crates/mvm-build/src/image_source/build.rs index 6fa04e6d89..70dc9f17f7 100644 --- a/crates/mvm-build/src/image_source/build.rs +++ b/crates/mvm-build/src/image_source/build.rs @@ -134,6 +134,7 @@ const DEFAULT_TENANT: TargetContract = TargetContract { files: &[ kernel("vmlinux", "default_tenant_workload_kernel"), file("rootfs.ext4", "default_tenant_workload_rootfs", "ext4"), + file("rootfs.tar.gz", "default_tenant_workload_rootfs", "tar_gz"), file( "rootfs.verity", "default_tenant_workload_rootfs", @@ -181,6 +182,7 @@ const ROOTLESS_TENANT: TargetContract = TargetContract { files: &[ kernel("vmlinux", "rootless_tenant_workload_kernel"), file("rootfs.ext4", "rootless_tenant_workload_rootfs", "ext4"), + file("rootfs.tar.gz", "rootless_tenant_workload_rootfs", "tar_gz"), file( "rootfs.verity", "rootless_tenant_workload_rootfs", @@ -1077,6 +1079,36 @@ mod tests { } } + #[test] + fn sealed_local_bases_carry_the_composition_tar_but_dev_roots_do_not() { + for role in [ + ImageBuildRole::DefaultTenant, + ImageBuildRole::RootlessTenant, + ] { + let files = contract_for(&target(role, "default")).unwrap().files; + assert!( + files.iter().any(|output| *output + == file( + "rootfs.tar.gz", + if role == ImageBuildRole::DefaultTenant { + "default_tenant_workload_rootfs" + } else { + "rootless_tenant_workload_rootfs" + }, + "tar_gz" + )), + "{role} sealed output must bind the exact tar" + ); + } + let dev = contract_for(&target(ImageBuildRole::DefaultTenant, "dev")) + .unwrap() + .files; + assert!( + dev.iter().all(|output| output.name != "rootfs.tar.gz"), + "unsealed dev output must not imply a composition tar" + ); + } + /// The builder-vm set is installed into the builder-VM cache, which /// requires every name in `BUILDER_VM_CACHE_ARTIFACTS`. A name the /// contract leaves out is never copied out of the build, so the install diff --git a/crates/mvm-build/src/lib.rs b/crates/mvm-build/src/lib.rs index 20eae02444..0e69cda159 100644 --- a/crates/mvm-build/src/lib.rs +++ b/crates/mvm-build/src/lib.rs @@ -4,6 +4,9 @@ //! Non-Unix hosts can observe repositories and produce/verify local manifests. //! They do not expose a VM launcher or a fallback implementation of one. +#[cfg(all(feature = "pr-test-synthetic-base", feature = "release-channel"))] +compile_error!("pr-test-synthetic-base is unavailable with release-channel"); + pub mod artifact_acquisition; pub mod base_image_contract; pub mod build_cache; @@ -16,6 +19,8 @@ pub mod embed_toolchain; pub use mvm_builder_guest::host_payload_manifest; pub mod image_protocol; pub mod image_source; +#[cfg(all(unix, feature = "pr-test-synthetic-base"))] +pub mod pr_test_synthetic_base; pub mod source_closure; mod source_copy; pub mod workspace_graph; diff --git a/crates/mvm-build/src/pr_test_synthetic_base.rs b/crates/mvm-build/src/pr_test_synthetic_base.rs new file mode 100644 index 0000000000..45cb26a3b2 --- /dev/null +++ b/crates/mvm-build/src/pr_test_synthetic_base.rs @@ -0,0 +1,361 @@ +//! PR-signed synthetic Linux inputs, admitted only through local development composition. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use mvm_core::arch::GuestArch; +use mvm_core::image_set::{ImageSetManifest, WorkloadImageProfile}; + +const ISSUER: &str = "https://token.actions.githubusercontent.com"; +const MAX_BUNDLE_BYTES: u64 = 1024 * 1024; + +/// A fixture selection, not a release image-set proof or a boot capability. +#[derive(Debug)] +pub struct PrTestSyntheticBase { + directory: PathBuf, + pull_request: u64, +} + +/// Paths and exact manifest bytes checked against the PR workflow signature. +#[derive(Debug)] +pub struct VerifiedSyntheticBase { + pub manifest: Vec, + pub manifest_path: PathBuf, + pub kernel: PathBuf, + pub tar: PathBuf, +} + +impl PrTestSyntheticBase { + /// PR number pinned to the exact workflow signing identity. + pub fn pull_request(&self) -> u64 { + self.pull_request + } + + /// Select a private PR fixture; verification is repeated before every use. + /// + /// # Errors + /// Refuses an absent PR number or a non-directory/symlink fixture. + pub fn new(directory: &Path, pull_request: u64) -> Result { + ensure!( + pull_request > 0, + "synthetic base requires a positive PR number" + ); + ensure!( + std::fs::symlink_metadata(directory)?.file_type().is_dir(), + "synthetic base fixture must be a real directory" + ); + Ok(Self { + directory: std::fs::canonicalize(directory)?, + pull_request, + }) + } + + /// Authenticate the exact local schema-3 root and selected regular kernel/tar. + /// + /// # Errors + /// Refuses changed bytes, the wrong PR signer, an invalid set or incompatible inputs. + pub fn verify(&self, arch: GuestArch) -> Result { + self.verify_with(arch, |manifest, bundle, identity| { + mvm_core::crypto::image_verify::verify_signed_payload( + manifest, bundle, identity, ISSUER, + ) + .map_err(Into::into) + }) + } + + fn verify_with( + &self, + arch: GuestArch, + verify_signature: impl FnOnce(&[u8], &[u8], &str) -> Result<()>, + ) -> Result { + let manifest_path = self.directory.join("image-set.json"); + let bundle_path = self.directory.join("image-set.bundle"); + let manifest = bounded_regular( + &manifest_path, + mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES, + )?; + let bundle = bounded_regular(&bundle_path, MAX_BUNDLE_BYTES)?; + let identity = format!( + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/{}/merge", + self.pull_request + ); + verify_signature(&manifest, &bundle, &identity) + .context("synthetic base manifest is not signed by the exact PR workflow")?; + let declared: ImageSetManifest = + serde_json::from_slice(&manifest).context("parse signed synthetic base manifest")?; + ensure!( + declared.schema_version == mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION, + "synthetic base requires schema 3" + ); + mvm_core::image_set::check_workload_protocol_compatibility( + &declared.compatibility, + &crate::stage0_kernel::current_image_set_protocol_support(), + )?; + let (kernel, tar) = mvm_core::plan::bundle::select_local_development_source( + &declared, + arch, + WorkloadImageProfile::DefaultTenant, + )?; + let selected = [(kernel, 256 * 1024 * 1024), (tar, 2 * 1024 * 1024 * 1024)]; + let mut paths = Vec::with_capacity(2); + for (artifact, maximum) in selected { + ensure!( + artifact.size <= maximum, + "synthetic base artifact exceeds capture bound" + ); + let path = self.directory.join(artifact.name.as_str()); + let metadata = std::fs::symlink_metadata(&path)?; + ensure!( + metadata.file_type().is_file() && metadata.len() == artifact.size, + "synthetic base artifact is missing, linked or changed" + ); + ensure!( + mvm_core::crypto::image_verify::sha256_file(&path)? == artifact.sha256.as_str(), + "synthetic base artifact digest changed" + ); + paths.push(path); + } + let [kernel, tar] = paths + .try_into() + .map_err(|_| anyhow::anyhow!("expected kernel and tar"))?; + Ok(VerifiedSyntheticBase { + manifest, + manifest_path, + kernel, + tar, + }) + } +} + +fn bounded_regular(path: &Path, max: u64) -> Result> { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("synthetic base input missing: {}", path.display()))?; + ensure!( + metadata.file_type().is_file() && metadata.len() <= max, + "synthetic base input must be a bounded regular file" + ); + let bytes = std::fs::read(path)?; + ensure!( + bytes.len() as u64 <= max, + "synthetic base input grew while reading" + ); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::{ + ArtifactFormat, BaseImageContract, GuestDeviceRequirement, ImageSetRole, MemberArtifact, + MemberTarget, + }; + use mvm_core::packs::Sha256Hex; + + fn local_fixture(dir: &Path) -> ImageSetManifest { + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let target = MemberTarget::Arch(arch); + let kernel = b"synthetic kernel"; + let tar = b"synthetic tar"; + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + target, + "kernel", + kernel.to_vec(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + target, + "rootfs.ext4", + b"ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == target + && matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) + }); + manifest.producer = serde_json::from_value(serde_json::json!({ + "local_checkouts": { + "images": { + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "worktree": {"state": "clean"} + } + } + })) + .unwrap(); + manifest.revocation_channel = None; + manifest.mvm_source_commit = None; + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("rootfs.tar.gz").unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(tar), + size: tar.len() as u64, + }); + } + _ => unreachable!("only selected workload members are retained"), + } + } + std::fs::write(dir.join("kernel"), kernel).unwrap(); + std::fs::write(dir.join("rootfs.tar.gz"), tar).unwrap(); + std::fs::write( + dir.join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(dir.join("image-set.bundle"), b"signed bundle").unwrap(); + manifest + } + + #[test] + fn exact_pr_identity_and_selected_bytes_are_required() { + let dir = tempfile::tempdir().unwrap(); + let mut manifest = local_fixture(dir.path()); + let release_producer = ImageSetFixture::complete().into_manifest().producer; + let fixture = PrTestSyntheticBase::new(dir.path(), 42).unwrap(); + let verify = || { + fixture.verify_with(GuestArch::host(), |bytes, bundle, identity| { + assert_eq!(bytes, std::fs::read(dir.path().join("image-set.json")).unwrap()); + assert_eq!(bundle, b"signed bundle"); + assert_eq!( + identity, + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/42/merge" + ); + Ok(()) + }) + }; + assert!(verify().is_ok()); + let foreign_arch = match GuestArch::host() { + GuestArch::X86_64 => GuestArch::Aarch64, + GuestArch::Aarch64 => GuestArch::X86_64, + }; + assert!(fixture.verify_with(foreign_arch, |_, _, _| Ok(())).is_err()); + std::fs::write(dir.path().join("kernel"), b"tampered kernel").unwrap(); + assert!(verify().is_err()); + std::fs::write(dir.path().join("kernel"), b"synthetic kernel").unwrap(); + std::fs::write(dir.path().join("rootfs.tar.gz"), b"changed tar").unwrap(); + assert!(verify().is_err()); + std::fs::write(dir.path().join("rootfs.tar.gz"), b"synthetic tar").unwrap(); + manifest.schema_version = 2; + std::fs::write( + dir.path().join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + assert!(verify().is_err()); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + manifest.producer = release_producer; + std::fs::write( + dir.path().join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + assert!( + verify().is_err(), + "release producer cannot become a PR-local base" + ); + } + + #[test] + fn absent_partial_or_unsigned_fixture_never_falls_back() { + let dir = tempfile::tempdir().unwrap(); + assert!(PrTestSyntheticBase::new(dir.path(), 0).is_err()); + let fixture = PrTestSyntheticBase::new(dir.path(), 42).unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + std::fs::write(dir.path().join("image-set.json"), b"{}").unwrap(); + std::fs::write(dir.path().join("image-set.bundle"), b"{}").unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + std::fs::remove_file(dir.path().join("image-set.bundle")).unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + } + + #[cfg(unix)] + #[test] + fn linked_manifest_is_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real"), b"{}").unwrap(); + std::os::unix::fs::symlink("real", dir.path().join("image-set.json")).unwrap(); + assert!( + PrTestSyntheticBase::new(dir.path(), 42) + .unwrap() + .verify(GuestArch::host()) + .is_err() + ); + } + + #[test] + #[ignore = "writes nonbootable synthetic fixture to MVM_PR_TEST_BASE_DIR for PR signing"] + fn write_synthetic_pr_base_fixture_for_ci() { + let path = PathBuf::from(std::env::var_os("MVM_PR_TEST_BASE_DIR").unwrap()); + std::fs::create_dir(&path).unwrap(); + local_fixture(&path); + } + + #[test] + #[ignore = "requires a real PR-workflow cosign bundle in MVM_PR_TEST_BASE_DIR"] + fn real_pr_signature_accepts_only_unchanged_exact_identity() { + let path = PathBuf::from(std::env::var_os("MVM_PR_TEST_BASE_DIR").unwrap()); + let number: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .unwrap() + .parse() + .unwrap(); + let arch = std::env::var("MVM_PR_TEST_ARCH") + .unwrap_or_else(|_| GuestArch::host().to_string()) + .parse::() + .expect("selected PR base architecture"); + let base = PrTestSyntheticBase::new(&path, number).unwrap(); + let checked = base + .verify(arch) + .expect("same-PR fixture must authenticate"); + assert!( + PrTestSyntheticBase::new(&path, number + 1) + .unwrap() + .verify(arch) + .is_err(), + "another PR must not authenticate the fixture" + ); + + let copied = tempfile::tempdir().unwrap(); + let bundle = path.join("image-set.bundle"); + for input in [ + checked.manifest_path.as_path(), + bundle.as_path(), + checked.kernel.as_path(), + checked.tar.as_path(), + ] { + std::fs::copy(input, copied.path().join(input.file_name().unwrap())).unwrap(); + } + let local = PrTestSyntheticBase::new(copied.path(), number).unwrap(); + assert!(local.verify(arch).is_ok()); + std::fs::write(copied.path().join("image-set.json"), b"{}").unwrap(); + assert!(local.verify(arch).is_err(), "changed manifest must fail"); + std::fs::write(copied.path().join("image-set.json"), &checked.manifest).unwrap(); + std::fs::write( + copied.path().join(checked.kernel.file_name().unwrap()), + b"changed", + ) + .unwrap(); + assert!(local.verify(arch).is_err(), "changed kernel must fail"); + std::fs::copy( + &checked.kernel, + copied.path().join(checked.kernel.file_name().unwrap()), + ) + .unwrap(); + std::fs::remove_file(copied.path().join(checked.tar.file_name().unwrap())).unwrap(); + assert!(local.verify(arch).is_err(), "missing tar must fail"); + } +} diff --git a/crates/mvm-build/src/published_image_set.rs b/crates/mvm-build/src/published_image_set.rs index e8bce5c95a..e6960f294d 100644 --- a/crates/mvm-build/src/published_image_set.rs +++ b/crates/mvm-build/src/published_image_set.rs @@ -360,6 +360,99 @@ impl PublishedImageSet { }) } + /// Verify a separately signed production base descriptor against this set. + /// + /// `artifact_dir` must contain the selected production kernel and composition + /// tar under their exact manifest names. No other set members are read or + /// fetched. The retained result refers to these files; keep them for rechecks. + /// Local or host-signed bases cannot satisfy the locked publisher identity. + /// + /// # Errors + /// Refuses missing or changed files, an unsigned or incompatible descriptor, + /// or missing, stale, or revoked publisher evidence. + pub fn verify_base_compatibility( + &self, + descriptor: &[u8], + signature_bundle: &[u8], + arch: GuestArch, + profile: WorkloadImageProfile, + artifact_dir: &Path, + ) -> Result { + self.with_base_verification(arch, profile, artifact_dir, |request| { + mvm_core::image_set::base_compatibility::verify_production_base_compatibility( + descriptor, + signature_bundle, + request, + arch, + profile, + ) + .map_err(Into::into) + }) + } + + /// Reverify held descriptor evidence against the original signed root and current feed. + /// + /// `artifact_dir` must still contain the selected kernel and tar under their + /// manifest names. The returned evidence replaces the earlier verification. + /// + /// # Errors + /// Refuses changed files, signatures, or expired or revoked evidence. + pub fn reverify_base_compatibility( + &self, + verified: &mvm_core::image_set::base_compatibility::ProductionBaseCompatibility, + artifact_dir: &Path, + ) -> Result { + self.with_base_verification( + verified.descriptor().arch(), + verified.descriptor().profile(), + artifact_dir, + |request| verified.reverify(request).map_err(Into::into), + ) + } + + fn with_base_verification( + &self, + arch: GuestArch, + profile: WorkloadImageProfile, + artifact_dir: &Path, + verify: impl FnOnce(&mvm_core::image_set::ImageSetVerification<'_>) -> Result, + ) -> Result { + anyhow::ensure!( + self.manifest.schema_version == mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION + && self.manifest.compatibility.base_image_contract + == Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER), + "production base compatibility requires a signed Linux-layer image set" + ); + anyhow::ensure!( + self.manifest.members.iter().any(|member| { + member.build_mode.is_none() + && member.role == ImageSetRole::WorkloadRootfs(profile) + && member.target == MemberTarget::Arch(arch) + && member.artifacts.iter().any(|artifact| { + artifact.format == mvm_core::image_set::ArtifactFormat::TarGz + }) + }), + "production base compatibility requires a signed composition tar" + ); + let proof = self.retained_publisher_proof()?; + let requirement = ImageSetRequirement::current_train(); + let host = crate::stage0_kernel::current_image_set_protocol_support(); + let request = mvm_core::image_set::ImageSetVerification::new( + proof.root().manifest_bytes(), + proof.root().signature_bundle(), + &mvm_core::image_set::image_train_lock().image_set, + artifact_dir, + ) + .require(&requirement) + .with_revocations(proof.revocations().verified()); + let request = if self.workload_only { + request.with_workload_protocols(&host) + } else { + request.with_host_protocols(&host) + }; + verify(&request) + } + pub fn member_cache(&self) -> SetMemberCache { SetMemberCache::for_root(self.root.clone()) } diff --git a/crates/mvm-build/src/published_image_set/local_proof.rs b/crates/mvm-build/src/published_image_set/local_proof.rs index c44a15f613..9318e8d045 100644 --- a/crates/mvm-build/src/published_image_set/local_proof.rs +++ b/crates/mvm-build/src/published_image_set/local_proof.rs @@ -54,16 +54,21 @@ pub fn retain_local_workload_proof(entry: &CachedImageSet, directory: &Path) -> "local proof directory is not a directory" ); for artifact in &entry.set.artifacts { - if entry.set.manifest.members.iter().any(|member| { - member.artifacts.iter().any(|declared| { - declared.name == artifact.name - && matches!( - declared.format, - mvm_core::image_set::ArtifactFormat::Json - | mvm_core::image_set::ArtifactFormat::Text - ) - }) - }) { + let format = entry + .set + .manifest + .members + .iter() + .flat_map(|member| &member.artifacts) + .find(|declared| declared.name == artifact.name) + .map(|declared| &declared.format); + if matches!( + format, + Some( + mvm_core::image_set::ArtifactFormat::Json + | mvm_core::image_set::ArtifactFormat::Text + ) + ) { let metadata = read_regular(&artifact.path)?; ensure!( metadata.len() as u64 == artifact.size @@ -75,6 +80,27 @@ pub fn retain_local_workload_proof(entry: &CachedImageSet, directory: &Path) -> &proof.join(artifact.name.as_str()), &metadata, )?; + } else if format == Some(&mvm_core::image_set::ArtifactFormat::TarGz) { + ensure!( + std::fs::symlink_metadata(&artifact.path)? + .file_type() + .is_file(), + "local composition source is not a regular file" + ); + let source = std::fs::File::open(&artifact.path)?; + let mut temp = tempfile::NamedTempFile::new_in(&proof)?; + let copied = std::io::copy( + &mut std::io::Read::take(source, artifact.size.saturating_add(1)), + temp.as_file_mut(), + )?; + ensure!( + copied == artifact.size + && mvm_core::crypto::image_verify::sha256_file(temp.path())? + == artifact.sha256.as_str(), + "local composition source changed after verification" + ); + temp.as_file_mut().sync_all()?; + temp.persist(proof.join(artifact.name.as_str()))?; } } mvm_core::util::atomic_io::atomic_write_durable( @@ -164,7 +190,7 @@ fn installed_paths( .filter(|file| { !matches!( file.format, - crate::image_source::OutputFormat::Fixed("json" | "text") + crate::image_source::OutputFormat::Fixed("json" | "text" | "tar_gz") ) }) .map(|file| file.name) diff --git a/crates/mvm-build/src/published_image_set/tests.rs b/crates/mvm-build/src/published_image_set/tests.rs index b708b47536..b445be5864 100644 --- a/crates/mvm-build/src/published_image_set/tests.rs +++ b/crates/mvm-build/src/published_image_set/tests.rs @@ -114,6 +114,76 @@ fn portable_publisher_proof_requires_current_feed_even_for_fixture_acquisition() assert!(!mvm_core::image_set::image_set_root_proof_cache().exists()); } +#[test] +fn production_base_verification_refuses_legacy_and_tarless_sets() { + let mut env = unsigned_env(); + let home = tempfile::tempdir().unwrap(); + env.isolate_mvm_home(home.path()); + let served = tempfile::tempdir().unwrap(); + let selected = tempfile::tempdir().unwrap(); + let role = ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant); + let target = MemberTarget::Arch(ARCH); + for (fixture, reason) in [ + (ImageSetFixture::complete(), "signed Linux-layer image set"), + ( + ImageSetFixture::complete() + .with_base_image_contract(Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER)) + .publish(role, target, "default-rootfs.ext4", b"ext4".to_vec()), + "signed composition tar", + ), + ] { + let set = + PublishedImageSet::acquire_workload_from(fixture.serve_from(served.path())).unwrap(); + let error = set + .verify_base_compatibility( + b"{}", + b"not a signature", + ARCH, + WorkloadImageProfile::DefaultTenant, + selected.path(), + ) + .unwrap_err(); + assert!(format!("{error:#}").contains(reason), "{error:#}"); + } + assert_eq!(std::fs::read_dir(selected.path()).unwrap().count(), 0); +} + +#[test] +fn production_base_verification_requires_retained_publisher_and_checkpointed_feed() { + let mut env = unsigned_env(); + let home = tempfile::tempdir().unwrap(); + env.isolate_mvm_home(home.path()); + let served = tempfile::tempdir().unwrap(); + let selected = tempfile::tempdir().unwrap(); + let role = ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant); + let target = MemberTarget::Arch(ARCH); + let fixture = ImageSetFixture::complete() + .with_base_image_contract(Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER)) + .publish(role, target, "default-rootfs.ext4", b"ext4".to_vec()) + .publish_extra(role, target, "default-rootfs.tar.gz", b"tar".to_vec()); + for acquire in [ + PublishedImageSet::acquire_from, + PublishedImageSet::acquire_workload_from, + ] { + let set = acquire(fixture.serve_from(served.path())).unwrap(); + let error = set + .verify_base_compatibility( + b"{}", + b"not a signature", + ARCH, + WorkloadImageProfile::DefaultTenant, + selected.path(), + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("current signed image-set revocations required"), + "{error:#}" + ); + } + assert_eq!(std::fs::read_dir(selected.path()).unwrap().count(), 0); + assert!(!mvm_core::image_set::image_set_root_proof_cache().exists()); +} + #[test] fn cached_compatibility_binding_accepts_matching_authenticated_contract_shape() { let expected = ImageSetFixture::complete() diff --git a/crates/mvm-build/src/runtime_layer.rs b/crates/mvm-build/src/runtime_layer.rs index 3b471f3c39..9f7acc51a2 100644 --- a/crates/mvm-build/src/runtime_layer.rs +++ b/crates/mvm-build/src/runtime_layer.rs @@ -9,9 +9,18 @@ use std::path::{Path, PathBuf}; use mvm_contract::pack_spec::PackTarget; use mvm_contract::pack_spec_v2::{BuildMode, ComposablePackError, ComposablePackSpec}; +use mvm_contract::pack_spec_v3::{ComposedGraph, GraphNode, SignedPackPin}; use mvm_core::arch::GuestArch; -use mvm_core::image_set::WorkloadImageProfile; +use mvm_core::image_set::base_compatibility::{ + DevelopmentBaseCompatibility, ProductionBaseCompatibility, +}; +use mvm_core::image_set::{ + ArtifactFormat, ImageSetManifest, ImageTrustTier, LocalImageSetVerification, + WorkloadImageProfile, +}; use mvm_core::packs::Sha256Hex; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::{ComponentLayerKind, VerifiedComponentLayerInputs}; use mvm_core::registry_pack::runtime_layer::{ OfflineRuntimePolicyError, RuntimeLayerArchiveError, RuntimeLayerInputError, RuntimeLayerPolicyError, RuntimeLayerResolveError, VerifiedRuntimeLayerArchive, @@ -27,6 +36,13 @@ use mvm_fs::ownership::OwnerTable; use serde::{Deserialize, Serialize}; use thiserror::Error; +mod component_lock; +mod entrypoint; +pub use component_lock::{ + ComponentArchivePin, ComponentFilesystemLockSchema, MaterializedComponentRootfs, + ResolvedComponentFilesystemLock, +}; + const MAX_UNPACKED_BYTES: u64 = 4 * 1024 * 1024 * 1024; const MAX_ENTRIES: u64 = 200_000; const MAX_LAYERS: usize = 64; @@ -117,6 +133,18 @@ pub struct ComposedWorkloadRootfs { pub struct ComposedApplicationRootfs { tree: ComposedWorkloadRootfs, application_archive_sha256: Sha256Hex, + entrypoint_script_sha256: Option, +} + +/// A private, authenticated ordered component tree; not a launch or export grant. +#[derive(Debug)] +pub struct ComposedComponentRootfs { + tree: ComposedWorkloadRootfs, + pins: Vec, + archives: Vec, + base_descriptor_sha256: Sha256Hex, + arch: GuestArch, + tier: ImageTrustTier, } /// This lock has its own schema; it never changes the v1 Nix pack lock. @@ -134,6 +162,8 @@ pub struct ResolvedComposableLock { spec_sha256: Sha256Hex, source_tree_sha256: Sha256Hex, application_archive_sha256: Sha256Hex, + #[serde(default, skip_serializing_if = "Option::is_none")] + entrypoint_script_sha256: Option, base_manifest_sha256: Sha256Hex, runtime_pins: Vec, target: PackTarget, @@ -169,6 +199,27 @@ impl ResolvedComposableLock { } Ok(()) } + + /// Identity of filesystem bytes, excluding policy-only decisions. + /// + /// The base and runtime manifest pins bind their archive digests. The + /// application archive digest binds every copied file; the selected + /// entrypoint script is also a filesystem input. A new assembler revision + /// changes the key even when its version number has not changed. + pub fn filesystem_identity(&self) -> anyhow::Result { + let bytes = serde_json::to_vec(&( + "mvm.composable-rootfs/v1", + &self.base_manifest_sha256, + &self.runtime_pins, + &self.application_archive_sha256, + &self.entrypoint_script_sha256, + self.target, + self.profile, + &self.producer_revision, + &self.producer_version, + ))?; + Ok(Sha256Hex::from_bytes(&bytes)) + } } impl ComposedApplicationRootfs { @@ -187,6 +238,25 @@ impl ComposedApplicationRootfs { self.tree.root() } + /// Bake the authored command into the sealed guest tree, not host grants. + /// + /// # Errors + /// Refuses another recipe or an occupied or unsafe guest marker path. + pub fn with_authored_entrypoint( + mut self, + spec: &ComposablePackSpec, + ) -> Result { + spec.validate()?; + if self.tree.authored_spec_sha256.as_ref() + != Some(&Sha256Hex::from_bytes(&spec.canonical_json()?)) + { + return Err(RuntimeLayerStageError::InconsistentLayers); + } + self.entrypoint_script_sha256 = + Some(entrypoint::write_script(self.root(), &spec.entrypoint)?); + Ok(self) + } + /// Bind one authored spec to the selected input graph after composition. /// /// The caller must reverify the held source/archive and producer revision @@ -229,19 +299,22 @@ impl ComposedApplicationRootfs { ) -> Result { let spec_sha256 = Sha256Hex::from_bytes(&spec.canonical_json()?); if self.tree.authored_spec_sha256.as_ref() != Some(&spec_sha256) - || producer_revision.len() != 40 - || !producer_revision - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !valid_producer_revision(producer_revision) { return Err(RuntimeLayerStageError::InconsistentLayers); } self.tree.reverify_runtime_policies()?; + let expected_script = Sha256Hex::from_bytes(&entrypoint::script(&spec.entrypoint)); + if self.entrypoint_script_sha256.as_ref() != Some(&expected_script) { + return Err(RuntimeLayerStageError::Entrypoint); + } + entrypoint::verify_script(self.root(), &expected_script)?; Ok(ResolvedComposableLock { schema: ComposableLockSchema::V1, spec_sha256, source_tree_sha256: source_tree_sha256.clone(), application_archive_sha256: self.application_archive_sha256.clone(), + entrypoint_script_sha256: self.entrypoint_script_sha256.clone(), base_manifest_sha256: self.tree.base_manifest_sha256.clone(), runtime_pins: self.tree.runtime_pins().to_vec(), target: spec.target, @@ -260,10 +333,24 @@ impl ComposedApplicationRootfs { &self, output: &Path, ) -> Result { - self.tree.materialize_candidate(output) + if let Some(digest) = &self.entrypoint_script_sha256 { + entrypoint::verify_script(self.root(), digest)?; + } + let image = self.tree.materialize_candidate(output)?; + if let Some(digest) = &self.entrypoint_script_sha256 { + entrypoint::verify_script(self.root(), digest)?; + } + Ok(image) } } +fn valid_producer_revision(revision: &str) -> bool { + revision.len() == 40 + && revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + impl ComposedWorkloadRootfs { fn reverify_runtime_policies(&self) -> Result<(), RuntimeLayerStageError> { if self.runtime_policies.len() != self.runtime_pins().len() @@ -335,6 +422,7 @@ impl ComposedWorkloadRootfs { Ok(ComposedApplicationRootfs { tree: self, application_archive_sha256: expected.clone(), + entrypoint_script_sha256: None, }) } @@ -421,6 +509,16 @@ impl ComposedRuntimeLayers { { return Err(RuntimeLayerStageError::Collision(path.clone())); } + if *kind == PathKind::Directory + && let Some(mode) = inventory.directory_modes.get(path) + && self.claimed.contains_key(path) + { + use std::os::unix::fs::PermissionsExt; + let existing = std::fs::symlink_metadata(self.root().join(path))?; + if !existing.is_dir() || existing.permissions().mode() & 0o7777 != *mode { + return Err(RuntimeLayerStageError::Collision(path.clone())); + } + } } let options = UnpackOptions::builder() .max_total_bytes(inventory.bytes) @@ -497,18 +595,301 @@ pub enum RuntimeLayerStageError { "host filesystem case-folds distinct guest paths; compose inside a case-sensitive Linux boundary" )] CaseFolding, - #[error("signed base acquisition failed: {0}")] + #[error("base acquisition failed: {0}")] Base(#[from] anyhow::Error), #[error("signed base composition source has invalid contract or ownership")] BaseContract, #[error("application archive bytes do not match the resolved input digest")] ApplicationDigest, + #[error("authored entrypoint changed or conflicts with the guest tree")] + Entrypoint, #[error("composable pack inputs differ from the frozen lock")] StaleLock, + #[error("ordered component graph, signed inputs or selected base disagree")] + ComponentGraph, + #[error("component archive differs from its signed descriptor")] + ComponentDigest, + #[error("component base requirements are not satisfied: {0}")] + ComponentBase(#[from] mvm_core::image_set::base_compatibility::BaseCompatibilityError), + #[error("application component input failed: {0}")] + ApplicationComponent( + #[from] mvm_core::registry_pack::application_component::ApplicationComponentInputError, + ), + #[error("runtime/tool component input failed: {0}")] + ComponentInput(#[from] mvm_core::registry_pack::component_layer::ComponentLayerInputError), #[error(transparent)] Materialize(#[from] crate::rootfs::RootfsError), } +/// Select the base for an ordered component graph without upgrading local evidence. +#[derive(Clone, Copy)] +pub enum ComponentGraphBase<'a> { + /// A publisher-verified production image set and its signed compatibility. + Production { + images: &'a crate::published_image_set::PublishedImageSet, + compatibility: &'a ProductionBaseCompatibility, + }, + /// An unsigned local schema-3 set, with exact selected kernel and tar paths. + Development { + compatibility: &'a DevelopmentBaseCompatibility, + evidence: &'a LocalImageSetVerification<'a>, + manifest_bytes: &'a [u8], + kernel: &'a Path, + tar: &'a Path, + }, +} + +/// Apply exact signed v3 components to a matching production or development base. +/// +/// This does not select an entrypoint, resolve policy, seal an image or export +/// a bundle. The caller must supply separately verified base compatibility and +/// held component inputs. Local evidence remains development-only, and its +/// checkout identities must be freshly sampled for this call. +/// +/// # Errors +/// +/// Refuses missing/extra/drifted inputs, unsatisfied requirements, unordered +/// dependencies, revoked signatures, changed archives and path collisions. +pub fn compose_verified_component_graph( + source: ComponentGraphBase<'_>, + graph: &ComposedGraph, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], +) -> Result { + graph + .validate() + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + if graph.graph.len() > MAX_LAYERS || graph.graph.len() != applications.len() + components.len() + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + let descriptor = match &source { + ComponentGraphBase::Production { compatibility, .. } => compatibility.descriptor(), + ComponentGraphBase::Development { compatibility, .. } => compatibility.descriptor(), + }; + let arch = descriptor.arch(); + let mut base = match &source { + ComponentGraphBase::Production { images, .. } => { + if descriptor.image_set_manifest_sha256() + != &Sha256Hex::from_bytes(images.manifest_bytes()) + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + compose_signed_workload_tree(images, arch, descriptor.profile(), &[])? + } + ComponentGraphBase::Development { .. } => compose_local_component_base(&source)?, + }; + let mut pins = Vec::with_capacity(graph.graph.len()); + let mut archives = Vec::with_capacity(graph.graph.len()); + for node in &graph.graph { + let requested = node.pin(); + let pin = graph_pin(requested)?; + match node { + GraphNode::Application { .. } => { + let input = applications + .iter() + .find(|input| { + input.descriptor().manifest().reference == *pin.reference() + && Sha256Hex::from_bytes(input.descriptor().manifest_bytes()) + == *pin.manifest_sha256() + }) + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let manifest = input.descriptor().manifest(); + if manifest.application_component.guest_arch != arch.to_string() { + return Err(RuntimeLayerStageError::ComponentGraph); + } + check_graph_requirements( + &source, + &manifest.application_component.requirements, + arch, + )?; + let archive = input.open_verified_archive()?; + apply_component_archive( + &mut base.tree, + archive, + &manifest.application_component.archive.archive_sha256, + TarRole::Application, + )?; + input.reverify()?; + archives.push(ComponentArchivePin { + sha256: manifest + .application_component + .archive + .archive_sha256 + .clone(), + size: manifest.application_component.archive.archive_size, + }); + } + GraphNode::Runtime { .. } | GraphNode::Tool { .. } => { + let input = components + .iter() + .find(|input| { + input.descriptor().manifest().reference() == pin.reference() + && input.descriptor().manifest_sha256() == pin.manifest_sha256() + }) + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let manifest = input.descriptor().manifest(); + let expected_kind = match node { + GraphNode::Runtime { .. } => ComponentLayerKind::Runtime, + GraphNode::Tool { .. } => ComponentLayerKind::Tool, + GraphNode::Application { .. } => unreachable!("matched component node"), + }; + if manifest.kind() != expected_kind + || manifest.component().guest_arch != arch.to_string() + || !dependencies_before(&manifest.component().dependencies, &pins) + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + check_graph_requirements(&source, &manifest.component().requirements, arch)?; + let archive = input.open_verified_archive()?; + apply_component_archive( + &mut base.tree, + archive, + &manifest.component().archive.archive_sha256, + TarRole::Runtime, + )?; + input.reverify()?; + archives.push(ComponentArchivePin { + sha256: manifest.component().archive.archive_sha256.clone(), + size: manifest.component().archive.archive_size, + }); + } + } + pins.push(pin); + } + // A later signer must not infer trust merely from this staged tree. + for input in applications { + input.reverify()?; + } + for input in components { + input.reverify()?; + } + let base_descriptor_sha256 = Sha256Hex::from_bytes(match &source { + ComponentGraphBase::Production { compatibility, .. } => compatibility.descriptor_bytes(), + ComponentGraphBase::Development { compatibility, .. } => compatibility.descriptor_bytes(), + }); + let tier = match source { + ComponentGraphBase::Production { images, .. } => { + images.retained_publisher_proof()?; + ImageTrustTier::VerifiedRelease + } + ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } => { + reverify_local_component_base(compatibility, evidence, manifest_bytes, kernel, tar)?; + ImageTrustTier::LocalDev + } + }; + Ok(ComposedComponentRootfs { + tree: base, + pins, + archives, + base_descriptor_sha256, + arch, + tier, + }) +} + +fn reverify_local_component_base( + compatibility: &DevelopmentBaseCompatibility, + evidence: &LocalImageSetVerification<'_>, + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, +) -> Result<(), RuntimeLayerStageError> { + let current = compatibility.reverify(evidence)?; + let (selected_kernel, selected_tar) = current.artifacts(); + if current.descriptor().image_set_manifest_sha256() != &Sha256Hex::from_bytes(manifest_bytes) + || selected_kernel.path != kernel + || selected_tar.path != tar + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + Ok(()) +} + +fn compose_local_component_base( + source: &ComponentGraphBase<'_>, +) -> Result { + let ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } = source + else { + return Err(RuntimeLayerStageError::ComponentGraph); + }; + reverify_local_component_base(compatibility, evidence, manifest_bytes, kernel, tar)?; + compose_local_workload_tree( + manifest_bytes, + kernel, + tar, + compatibility.descriptor().arch(), + compatibility.descriptor().profile(), + &[], + ) +} + +fn check_graph_requirements( + source: &ComponentGraphBase<'_>, + requirements: &mvm_core::registry_pack::application_component::ComponentRequirements, + arch: GuestArch, +) -> Result<(), RuntimeLayerStageError> { + match source { + ComponentGraphBase::Production { compatibility, .. } => { + compatibility.check_component_requirements(requirements, arch)?; + } + ComponentGraphBase::Development { compatibility, .. } => { + compatibility.check_component_requirements(requirements, arch)?; + } + } + Ok(()) +} + +fn graph_pin(requested: &SignedPackPin) -> Result { + let reference = format!("{}@{}", requested.name, requested.version) + .parse() + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let digest = Sha256Hex::new(&requested.manifest_sha256) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + PackPin::new(reference, digest).map_err(|_| RuntimeLayerStageError::ComponentGraph) +} + +fn dependencies_before(dependencies: &[PackPin], preceding: &[PackPin]) -> bool { + dependencies.iter().all(|pin| preceding.contains(pin)) +} + +fn apply_component_archive( + tree: &mut ComposedRuntimeLayers, + mut archive: std::fs::File, + expected: &Sha256Hex, + role: TarRole, +) -> Result<(), RuntimeLayerStageError> { + let size = archive.metadata()?.len(); + if mvm_core::crypto::image_verify::sha256_reader(&mut archive)? != expected.as_str() { + return Err(RuntimeLayerStageError::ComponentDigest); + } + archive.seek(SeekFrom::Start(0))?; + let mut magic = [0; 2]; + archive.read_exact(&mut magic)?; + archive.seek(SeekFrom::Start(0))?; + if magic == [0x1f, 0x8b] { + let mut tar = tempfile::tempfile()?; + decompress_single_gzip(io::BufReader::new(archive), size, &mut tar)?; + tar.seek(SeekFrom::Start(0))?; + tree.apply_tar(&mut tar, role)?; + } else { + tree.apply_tar(&mut archive, role)?; + } + Ok(()) +} + /// Verify revocation and snapshot custody around strict, bounded extraction. /// /// # Errors @@ -565,6 +946,35 @@ pub fn compose_authored_candidate( images: &crate::published_image_set::PublishedImageSet, profile: WorkloadImageProfile, inputs: &[&VerifiedRuntimeLayerInputs], +) -> Result { + compose_authored_candidate_with(spec, inputs, |arch, archives| { + compose_signed_workload_tree(images, arch, profile, archives) + }) +} + +/// Compose an explicitly selected local-development base. The local manifest +/// must declare both input files; its ext4 artifact is not treated as the +/// composition tree or as evidence that it matches the tar. +pub fn compose_local_authored_candidate( + spec: &ComposablePackSpec, + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, + profile: WorkloadImageProfile, + inputs: &[&VerifiedRuntimeLayerInputs], +) -> Result { + compose_authored_candidate_with(spec, inputs, |arch, archives| { + compose_local_workload_tree(manifest_bytes, kernel, tar, arch, profile, archives) + }) +} + +fn compose_authored_candidate_with( + spec: &ComposablePackSpec, + inputs: &[&VerifiedRuntimeLayerInputs], + select_base: impl FnOnce( + GuestArch, + &[&VerifiedRuntimeLayerArchive], + ) -> Result, ) -> Result { spec.validate()?; for input in inputs { @@ -589,7 +999,7 @@ pub fn compose_authored_candidate( PackTarget::X86_64Linux => GuestArch::X86_64, PackTarget::Aarch64Linux => GuestArch::Aarch64, }; - let mut composed = compose_signed_workload_tree(images, arch, profile, &archives)?; + let mut composed = select_base(arch, &archives)?; for input in inputs { // add_runtime_archives already rehashes each archive before and after // extraction; the small signed policy bytes still need a final check. @@ -603,6 +1013,98 @@ pub fn compose_authored_candidate( Ok(composed) } +/// Require a local schema-3 dev pair with its own declared tar, then use +/// exactly those bytes as the base and selected kernel for a dev-only build. +pub fn compose_local_workload_tree( + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, + arch: GuestArch, + profile: WorkloadImageProfile, + archives: &[&VerifiedRuntimeLayerArchive], +) -> Result { + if manifest_bytes.len() > mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES as usize { + return Err(RuntimeLayerStageError::Unsupported( + "local base manifest exceeds its bound", + )); + } + let manifest: ImageSetManifest = serde_json::from_slice(manifest_bytes) + .map_err(|_| RuntimeLayerStageError::Unsupported("invalid local base manifest"))?; + mvm_core::image_set::check_workload_protocol_compatibility( + &manifest.compatibility, + &crate::stage0_kernel::current_image_set_protocol_support(), + ) + .map_err(|error| RuntimeLayerStageError::Base(error.into()))?; + let (kernel_artifact, tar_artifact) = + mvm_core::plan::bundle::select_local_development_source(&manifest, arch, profile) + .map_err(|error| RuntimeLayerStageError::Base(error.into()))?; + let ArtifactFormat::Kernel(declared_kernel_format) = kernel_artifact.format else { + return Err(RuntimeLayerStageError::Unsupported( + "local dev kernel format is invalid", + )); + }; + if kernel_artifact.size > 256 * 1024 * 1024 { + return Err(RuntimeLayerStageError::Unsupported( + "local base kernel exceeds the bundle capture bound", + )); + } + if tar_artifact.size > 2 * 1024 * 1024 * 1024 { + return Err(RuntimeLayerStageError::Unsupported( + "local base tar exceeds its bound", + )); + } + for (path, artifact) in [(kernel, kernel_artifact), (tar, tar_artifact)] { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() + || metadata.len() != artifact.size + || mvm_core::crypto::image_verify::sha256_file(path)? != artifact.sha256.as_str() + { + return Err(RuntimeLayerStageError::Unsupported( + "local base kernel or composition tar differs from its declared bytes", + )); + } + } + let mut head = vec![0; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + std::io::Read::read_exact(&mut std::fs::File::open(kernel)?, &mut head)?; + let actual_kernel_format = mvm_core::kernel_format::KernelFormat::sniff_magic(&head); + let expected_machine = match arch { + GuestArch::X86_64 => 62u16, + GuestArch::Aarch64 => 183u16, + }; + if actual_kernel_format != Some(declared_kernel_format) + || (declared_kernel_format == mvm_core::kernel_format::KernelFormat::Elf + && head.get(18..20) != Some(expected_machine.to_le_bytes().as_slice())) + { + return Err(RuntimeLayerStageError::Unsupported( + "local kernel bytes do not match the declared format or architecture", + )); + } + let pins = runtime_pins(archives, &arch.to_string())?; + let mut decompressed = tempfile::tempfile()?; + decompress_single_gzip( + io::BufReader::new(std::fs::File::open(tar)?), + tar_artifact.size, + &mut decompressed, + )?; + if mvm_core::crypto::image_verify::sha256_file(tar)? != tar_artifact.sha256.as_str() { + return Err(RuntimeLayerStageError::Unsupported( + "local base tar changed while decompressing", + )); + } + decompressed.seek(SeekFrom::Start(0))?; + let mut composed = ComposedRuntimeLayers::new(pins)?; + composed.add_base_tar(&mut decompressed)?; + check_base_contract(&composed)?; + add_runtime_archives(&mut composed, archives)?; + Ok(ComposedWorkloadRootfs { + tree: composed, + base_manifest_sha256: Sha256Hex::from_bytes(manifest_bytes), + profile, + authored_spec_sha256: None, + runtime_policies: Vec::new(), + }) +} + /// Combine a signed generic base and ordered runtime layers without Nix. /// /// A verified image-set root selects the production tar; its compressed bytes @@ -1075,6 +1577,109 @@ mod tests { } } + #[test] + fn component_archive_refuses_tamper_before_extraction() { + use std::io::Write as _; + + let bytes = archive(&[("bin/tool", b"trusted", tar::EntryType::Regular, 0o755)]); + let mut snapshot = tempfile::tempfile().unwrap(); + snapshot.write_all(&bytes).unwrap(); + snapshot.rewind().unwrap(); + let mut accepted = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + apply_component_archive( + &mut accepted, + snapshot.try_clone().unwrap(), + &Sha256Hex::from_bytes(&bytes), + TarRole::Runtime, + ) + .unwrap(); + assert_eq!( + std::fs::read(accepted.root().join("bin/tool")).unwrap(), + b"trusted" + ); + snapshot.rewind().unwrap(); + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + let wrong = Sha256Hex::from_bytes(b"different signed archive"); + assert!(matches!( + apply_component_archive(&mut tree, snapshot, &wrong, TarRole::Runtime), + Err(RuntimeLayerStageError::ComponentDigest) + )); + assert!(!tree.root().join("bin/tool").exists()); + } + + #[test] + fn component_accepts_digest_pinned_gzip_without_an_extension_rule() { + use std::io::Write as _; + + let raw = archive(&[("bin/tool", b"compressed", tar::EntryType::Regular, 0o755)]); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&raw).unwrap(); + let gzip = encoder.finish().unwrap(); + let mut snapshot = tempfile::tempfile().unwrap(); + snapshot.write_all(&gzip).unwrap(); + snapshot.rewind().unwrap(); + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + apply_component_archive( + &mut tree, + snapshot, + &Sha256Hex::from_bytes(&gzip), + TarRole::Runtime, + ) + .unwrap(); + assert_eq!( + std::fs::read(tree.root().join("bin/tool")).unwrap(), + b"compressed" + ); + } + + #[test] + fn component_dependencies_require_exact_preceding_manifest_pins() { + let reference = "runtime/python@1.0.0".parse().unwrap(); + let pinned = PackPin::new(reference, Sha256Hex::from_bytes(b"trusted manifest")).unwrap(); + let drift = PackPin::new( + pinned.reference().clone(), + Sha256Hex::from_bytes(b"different manifest"), + ) + .unwrap(); + assert!(!dependencies_before(std::slice::from_ref(&pinned), &[])); + assert!(!dependencies_before( + std::slice::from_ref(&pinned), + &[drift] + )); + assert!(dependencies_before( + std::slice::from_ref(&pinned), + std::slice::from_ref(&pinned) + )); + } + + #[test] + fn ordered_components_refuse_leaf_and_directory_mode_collisions() { + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + let first = archive(&[ + ("usr", b"", tar::EntryType::Directory, 0o755), + ("usr/bin/tool", b"first", tar::EntryType::Regular, 0o755), + ]); + tree.add_tar(&mut Cursor::new(first)).unwrap(); + let overlapping = archive(&[("usr/bin/tool", b"second", tar::EntryType::Regular, 0o755)]); + assert!(matches!( + tree.add_tar(&mut Cursor::new(overlapping)), + Err(RuntimeLayerStageError::Collision(_)) + )); + let changed_mode = archive(&[ + ("usr", b"", tar::EntryType::Directory, 0o777), + ("usr/bin/other", b"other", tar::EntryType::Regular, 0o755), + ]); + assert!(matches!( + tree.add_tar(&mut Cursor::new(changed_mode)), + Err(RuntimeLayerStageError::Collision(_)) + )); + assert_eq!( + std::fs::read(tree.root().join("usr/bin/tool")).unwrap(), + b"first" + ); + assert!(!tree.root().join("usr/bin/other").exists()); + } + #[test] fn runtime_tar_refuses_changes_to_earlier_layers_and_boot() { for path in [ @@ -1255,6 +1860,413 @@ mod tests { } } + #[test] + fn local_composition_uses_only_the_declared_kernel_and_tar() { + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::{ + BaseImageContract, BuilderBootAbi, GuestDeviceRequirement, ImageSetRole, + MemberArtifact, MemberTarget, + }; + + let directory = tempfile::tempdir().unwrap(); + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let kernel = directory.path().join("vmlinux"); + let tar = directory.path().join("rootfs.tar.gz"); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &base_tar(b"1\n", 901)).unwrap(); + let tar_bytes = encoder.finish().unwrap(); + let mut kernel_bytes = vec![0u8; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + match arch { + GuestArch::X86_64 => { + kernel_bytes[..4].copy_from_slice(b"\x7fELF"); + kernel_bytes[18..20].copy_from_slice(&62u16.to_le_bytes()); + } + GuestArch::Aarch64 => { + kernel_bytes[56..60].copy_from_slice(&0x644D_5241u32.to_le_bytes()); + } + } + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, &tar_bytes).unwrap(); + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + MemberTarget::Arch(arch), + "local-vmlinux", + kernel_bytes.clone(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + MemberTarget::Arch(arch), + "local-rootfs.ext4", + b"unrelated ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(arch) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) + }); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + let release_producer = manifest.producer.clone(); + manifest.producer = serde_json::from_value(serde_json::json!({ + "local_checkouts": { + "images": { + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "worktree": {"state": "clean"} + } + } + })) + .unwrap(); + manifest.mvm_source_commit = None; + manifest.revocation_channel = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("local-rootfs.tar.gz") + .unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(&tar_bytes), + size: tar_bytes.len() as u64, + }); + } + _ => unreachable!(), + } + } + let bytes = serde_json::to_vec(&manifest).unwrap(); + let composed = + compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).unwrap(); + assert_eq!( + std::fs::read(composed.root().join("usr/bin/sh")).unwrap(), + b"busybox" + ); + assert_eq!( + composed.base_manifest_sha256(), + &Sha256Hex::from_bytes(&bytes) + ); + drop(composed); + + std::fs::write(&kernel, b"forged kernel").unwrap(); + assert!(compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).is_err()); + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, b"unrelated ext4").unwrap(); + assert!(compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).is_err()); + std::fs::write(&tar, &tar_bytes).unwrap(); + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::LEGACY); + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err() + ); + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + manifest + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .retain(|artifact| artifact.format != ArtifactFormat::TarGz); + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err(), + "an ext4 declaration is not a composition tree" + ); + manifest.producer = release_producer; + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err(), + "a release claim cannot enter the local lane" + ); + } + + #[test] + fn component_graph_local_base_is_development_and_rejects_mismatches_and_tampering() { + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::base_compatibility::verify_development_base_compatibility; + use mvm_core::image_set::{ + BaseImageContract, BuilderBootAbi, GuestDeviceRequirement, ImageSetProducer, + ImageSetRole, LocalCheckouts, MemberArtifact, MemberTarget, + }; + use mvm_core::registry_pack::application_component::ComponentRequirements; + + let directory = tempfile::tempdir().unwrap(); + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let mut kernel_bytes = vec![0; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + match arch { + GuestArch::X86_64 => { + kernel_bytes[..4].copy_from_slice(b"\x7fELF"); + kernel_bytes[18..20].copy_from_slice(&62u16.to_le_bytes()); + } + GuestArch::Aarch64 => { + kernel_bytes[56..60].copy_from_slice(&0x644D_5241u32.to_le_bytes()); + } + } + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &base_tar(b"1\n", 901)).unwrap(); + let tar_bytes = encoder.finish().unwrap(); + let kernel = directory.path().join("local-vmlinux"); + let tar = directory.path().join("local-rootfs.tar.gz"); + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, &tar_bytes).unwrap(); + std::fs::write(directory.path().join("local-rootfs.ext4"), b"ext4").unwrap(); + let current: LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "worktree": {"state": "clean"}} + })) + .unwrap(); + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + MemberTarget::Arch(arch), + "local-vmlinux", + kernel_bytes.clone(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + MemberTarget::Arch(arch), + "local-rootfs.ext4", + b"ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(arch) + && (member.role == ImageSetRole::WorkloadKernel(profile) + || member.role == ImageSetRole::WorkloadRootfs(profile)) + }); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + manifest.producer = ImageSetProducer::LocalCheckouts(current.clone()); + manifest.mvm_source_commit = None; + manifest.revocation_channel = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("local-rootfs.tar.gz") + .unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(&tar_bytes), + size: tar_bytes.len() as u64, + }); + } + _ => unreachable!(), + } + } + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + let descriptor_bytes = serde_json::to_vec(&serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": Sha256Hex::from_bytes(&manifest_bytes), + "arch": if arch == GuestArch::X86_64 { "x86_64" } else { "aarch64" }, + "profile": "default_tenant", + "kernel": {"name": "local-vmlinux", "sha256": Sha256Hex::from_bytes(&kernel_bytes), "size": kernel_bytes.len()}, + "composition_tar": {"name": "local-rootfs.tar.gz", "sha256": Sha256Hex::from_bytes(&tar_bytes), "size": tar_bytes.len()}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": if arch == GuestArch::X86_64 { "/lib/ld-musl-x86_64.so.1" } else { "/lib/ld-musl-aarch64.so.1" }}], + "kernel_capabilities": ["virtio_vsock"] + })) + .unwrap(); + let evidence = + LocalImageSetVerification::new(&manifest_bytes, directory.path(), ¤t, arch); + let compatibility = + verify_development_base_compatibility(&descriptor_bytes, &evidence, arch, profile) + .unwrap(); + let source = ComponentGraphBase::Development { + compatibility: &compatibility, + evidence: &evidence, + manifest_bytes: &manifest_bytes, + kernel: &kernel, + tar: &tar, + }; + let base = compose_local_component_base(&source).unwrap(); + assert_eq!( + std::fs::read(base.root().join("usr/bin/sh")).unwrap(), + b"busybox" + ); + assert_eq!( + base.base_manifest_sha256(), + &Sha256Hex::from_bytes(&manifest_bytes) + ); + let candidate = ComposedComponentRootfs { + tree: base, + pins: Vec::new(), + archives: Vec::new(), + base_descriptor_sha256: Sha256Hex::from_bytes(&descriptor_bytes), + arch, + tier: ImageTrustTier::LocalDev, + }; + let output = tempfile::tempdir().unwrap(); + let image = output.path().join("rootfs.ext4"); + assert!( + candidate + .materialize_candidate(&image, "not-a-revision", source, &[], &[]) + .is_err() + ); + assert!(!image.exists()); + let materialized = candidate + .materialize_candidate(&image, &"a".repeat(40), source, &[], &[]) + .unwrap(); + assert_eq!(materialized.lock.tier(), ImageTrustTier::LocalDev); + materialized + .lock + .verify_materialized_bytes(&materialized.image) + .unwrap(); + let encoded = serde_json::to_vec(&materialized.lock).unwrap(); + let restored: ResolvedComponentFilesystemLock = serde_json::from_slice(&encoded).unwrap(); + restored.verify_current(&materialized.lock).unwrap(); + let filesystem = restored.filesystem_identity().unwrap(); + let mut changed = restored.clone(); + changed.verity_sha256 = Sha256Hex::from_bytes(b"changed output"); + assert_eq!(changed.filesystem_identity().unwrap(), filesystem); + assert!(restored.verify_current(&changed).is_err()); + let reference = "agent/example@1.0.0".parse().unwrap(); + changed = restored.clone(); + changed.components.push(( + PackPin::new(reference, Sha256Hex::from_bytes(b"first signed manifest")).unwrap(), + ComponentArchivePin { + sha256: Sha256Hex::from_bytes(b"application bytes"), + size: b"application bytes".len() as u64, + }, + )); + let archive_identity = changed.filesystem_identity().unwrap(); + changed.components[0].0 = PackPin::new( + "agent/example@1.0.0".parse().unwrap(), + Sha256Hex::from_bytes(b"changed signed policy or command"), + ) + .unwrap(); + assert_eq!(changed.filesystem_identity().unwrap(), archive_identity); + changed.components[0].1.sha256 = Sha256Hex::from_bytes(b"different application bytes"); + assert_ne!(changed.filesystem_identity().unwrap(), archive_identity); + changed = restored.clone(); + changed.producer_revision = "b".repeat(40); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); + changed = restored.clone(); + changed.base_tier = ImageTrustTier::VerifiedRelease; + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); + std::fs::write(output.path().join("rootfs.roothash"), b"tampered").unwrap(); + assert!( + restored + .verify_materialized_bytes(&materialized.image) + .is_err() + ); + let requirements = ComponentRequirements { + filesystem_abi: 1, + libc: "musl".into(), + loader: Some( + if arch == GuestArch::X86_64 { + "/lib/ld-musl-x86_64.so.1" + } else { + "/lib/ld-musl-aarch64.so.1" + } + .into(), + ), + minimum_kernel: "6.12.3".into(), + required_capabilities: vec!["virtio_vsock".into()], + }; + check_graph_requirements(&source, &requirements, arch).unwrap(); + let mut incompatible = requirements.clone(); + incompatible.minimum_kernel = "6.12.4".into(); + assert!(matches!( + check_graph_requirements(&source, &incompatible, arch), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + + let wrong_manifest = b"another image set"; + assert!(matches!( + reverify_local_component_base(&compatibility, &evidence, wrong_manifest, &kernel, &tar), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let alternate_kernel = directory.path().join("alternate-vmlinux"); + std::fs::write(&alternate_kernel, &kernel_bytes).unwrap(); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &evidence, + &manifest_bytes, + &alternate_kernel, + &tar + ), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let alternate_tar = directory.path().join("alternate-rootfs.tar.gz"); + std::fs::write(&alternate_tar, &tar_bytes).unwrap(); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &evidence, + &manifest_bytes, + &kernel, + &alternate_tar + ), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let changed_checkout: LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worktree": {"state": "clean"}} + })) + .unwrap(); + let stale_evidence = LocalImageSetVerification::new( + &manifest_bytes, + directory.path(), + &changed_checkout, + arch, + ); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &stale_evidence, + &manifest_bytes, + &kernel, + &tar + ), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + std::fs::write(&tar, b"tampered").unwrap(); + assert!(matches!( + compose_local_component_base(&source), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + } + #[test] fn one_application_is_sealed_into_the_composed_rootfs() { use std::io::Write as _; @@ -1548,7 +2560,13 @@ mod tests { let digest = Sha256Hex::new(prepared.archive_sha256().to_owned()).unwrap(); let application = composed .with_application_archive(prepared.archive_bytes(), &digest) + .unwrap() + .with_authored_entrypoint(&spec) .unwrap(); + assert_eq!( + std::fs::read(application.root().join("etc/mvm/entrypoint")).unwrap(), + b"#!/bin/sh\nexec '/srv/app/probe.sh'\n" + ); prepared.reverify().unwrap(); let source_sha256 = Sha256Hex::new(prepared.source_sha256().to_owned()).unwrap(); let revision = "a".repeat(40); @@ -1591,6 +2609,13 @@ mod tests { let locked_bytes = serde_json::to_vec(&lock).unwrap(); let restored: ResolvedComposableLock = serde_json::from_slice(&locked_bytes).unwrap(); restored.verify_current(&lock).unwrap(); + let filesystem = restored.filesystem_identity().unwrap(); + let mut command_only = lock.clone(); + command_only.spec_sha256 = Sha256Hex::from_bytes(b"another admitted command"); + assert_ne!(command_only, lock); + assert_eq!(command_only.filesystem_identity().unwrap(), filesystem); + command_only.entrypoint_script_sha256 = Some(Sha256Hex::from_bytes(b"another script")); + assert_ne!(command_only.filesystem_identity().unwrap(), filesystem); let mut changed = lock.clone(); changed.source_tree_sha256 = Sha256Hex::from_bytes(b"edited source"); assert!(matches!( @@ -1599,12 +2624,14 @@ mod tests { )); changed = lock.clone(); changed.base_manifest_sha256 = Sha256Hex::from_bytes(b"replaced base"); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); assert!(matches!( restored.verify_current(&changed), Err(RuntimeLayerStageError::StaleLock) )); changed = lock.clone(); changed.application_archive_sha256 = Sha256Hex::from_bytes(b"changed application"); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); assert!(matches!( restored.verify_current(&changed), Err(RuntimeLayerStageError::StaleLock) @@ -1679,6 +2706,13 @@ mod tests { .unwrap(), b"print('ready')\n" ); + assert_eq!( + mvm_fs::tree_diff::Ext4Tree::open(&image) + .unwrap() + .read_regular_file_no_symlinks("etc/mvm/entrypoint", 128) + .unwrap(), + b"#!/bin/sh\nexec '/srv/app/probe.sh'\n" + ); if real_tar.is_some() && std::env::var_os("MVM_TEST_SIGNED_BASE_VERIFY").is_some() { let directory = PathBuf::from(std::env::var_os("MVM_TEST_SIGNED_BASE_DIR").unwrap()); std::fs::copy(&image, directory.join("composed.ext4")).unwrap(); diff --git a/crates/mvm-build/src/runtime_layer/component_lock.rs b/crates/mvm-build/src/runtime_layer/component_lock.rs new file mode 100644 index 0000000000..479907ef90 --- /dev/null +++ b/crates/mvm-build/src/runtime_layer/component_lock.rs @@ -0,0 +1,326 @@ +//! Filesystem-only identity and measured materialization of a verified component graph. + +use std::path::Path; + +use mvm_core::arch::GuestArch; +use mvm_core::image_set::{ImageTrustTier, WorkloadImageProfile}; +use mvm_core::packs::Sha256Hex; +use mvm_core::registry_pack::PackPin; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::VerifiedComponentLayerInputs; +use serde::{Deserialize, Serialize}; + +use super::{ + ComponentGraphBase, ComposedComponentRootfs, RuntimeLayerStageError, + reverify_local_component_base, valid_producer_revision, +}; + +/// Exact archive bytes declared by one signed graph component. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentArchivePin { + pub sha256: Sha256Hex, + pub size: u64, +} + +/// Filesystem-only v3 graph evidence, not a policy, command or signing grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComponentFilesystemLockSchema { + #[serde(rename = "mvm.composed-filesystem-lock/v1")] + V1, +} + +/// Exact inputs and measured output of a private composed filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResolvedComponentFilesystemLock { + schema: ComponentFilesystemLockSchema, + base_manifest_sha256: Sha256Hex, + base_descriptor_sha256: Sha256Hex, + pub(super) base_tier: ImageTrustTier, + arch: GuestArch, + profile: WorkloadImageProfile, + pub(super) components: Vec<(PackPin, ComponentArchivePin)>, + pub(super) producer_revision: String, + producer_version: String, + ext4_sha256: Sha256Hex, + pub(super) verity_sha256: Sha256Hex, + verity_root_hash: Sha256Hex, +} + +impl ResolvedComponentFilesystemLock { + /// Hash the exact filesystem inputs, excluding output bytes and launch decisions. + /// + /// # Errors + /// + /// Returns a serialization error if a lock field cannot be encoded. + pub fn filesystem_identity(&self) -> anyhow::Result { + let archives: Vec<&ComponentArchivePin> = + self.components.iter().map(|(_, archive)| archive).collect(); + let bytes = serde_json::to_vec(&( + "mvm.composed-filesystem/v1", + &self.base_manifest_sha256, + &self.base_descriptor_sha256, + self.base_tier, + self.arch, + self.profile, + archives, + &self.producer_revision, + &self.producer_version, + ))?; + Ok(Sha256Hex::from_bytes(&bytes)) + } + + /// The base trust tier cannot be promoted by a composed filesystem lock. + pub fn tier(&self) -> ImageTrustTier { + self.base_tier + } + + /// Digest of the exact written ext4 image. + pub fn ext4_sha256(&self) -> &Sha256Hex { + &self.ext4_sha256 + } + + /// Digest of the exact written dm-verity hash-tree sidecar. + pub fn verity_sha256(&self) -> &Sha256Hex { + &self.verity_sha256 + } + + /// Measured dm-verity root hash, not a health or boot witness. + pub fn verity_root_hash(&self) -> &Sha256Hex { + &self.verity_root_hash + } + + /// Compare freshly resolved input and output pins before consuming this lock again. + /// + /// # Errors + /// + /// Refuses a different graph, assembler, base or materialized result. + pub fn verify_current(&self, current: &Self) -> Result<(), RuntimeLayerStageError> { + if self != current { + return Err(RuntimeLayerStageError::StaleLock); + } + Ok(()) + } + + /// Rehash the materialized image and sidecars against this filesystem lock. + /// + /// # Errors + /// + /// Refuses changed ext4, verity or root-hash bytes. + pub fn verify_materialized_bytes( + &self, + image: &crate::rootfs::MaterializedExt4, + ) -> Result<(), RuntimeLayerStageError> { + let (ext4, verity, root) = measure_materialized_component(image)?; + if ext4 != self.ext4_sha256 || verity != self.verity_sha256 || root != self.verity_root_hash + { + return Err(RuntimeLayerStageError::StaleLock); + } + Ok(()) + } +} + +/// Private image and its filesystem-only lock; neither grants launch or export. +#[derive(Debug)] +pub struct MaterializedComponentRootfs { + pub image: crate::rootfs::MaterializedExt4, + pub lock: ResolvedComponentFilesystemLock, +} + +impl ComposedComponentRootfs { + /// Trust tier of the selected base; a local base remains development-only. + pub fn tier(&self) -> ImageTrustTier { + self.tier + } + + /// The staged tree, which remains private until separately sealed and admitted. + pub fn root(&self) -> &Path { + self.tree.root() + } + + /// Original file owners to preserve during later image materialization. + pub fn ownership(&self) -> &mvm_fs::ownership::OwnerTable { + self.tree.ownership() + } + + /// Exact signed manifest pins in filesystem application order. + pub fn pins(&self) -> &[PackPin] { + &self.pins + } + + /// Write ext4 and verity using the existing composer, binding measured bytes. + /// + /// The caller retains the same verified base and component inputs. Neither + /// this filesystem-only lock nor the image selects policy or a launch command. + /// `producer_revision` must be the caller's compiled `MVM_SOURCE_COMMIT`, + /// never an authored value. A production export must separately reverify + /// the signed base descriptor with its held image-set verification request; + /// `PublishedImageSet` alone cannot reconstruct that request here. + /// + /// # Errors + /// + /// Refuses changed producer, base, component or output bytes. + pub fn materialize_candidate( + &self, + output: &Path, + producer_revision: &str, + base: ComponentGraphBase<'_>, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], + ) -> Result { + if !valid_producer_revision(producer_revision) { + return Err(RuntimeLayerStageError::ComponentGraph); + } + self.reverify_base(&base)?; + self.reverify_components(applications, components)?; + let image = self.tree.materialize_candidate(output)?; + self.reverify_base(&base)?; + self.reverify_components(applications, components)?; + let (ext4_sha256, verity_sha256, verity_root_hash) = + measure_materialized_component(&image)?; + let lock = ResolvedComponentFilesystemLock { + schema: ComponentFilesystemLockSchema::V1, + base_manifest_sha256: self.tree.base_manifest_sha256.clone(), + base_descriptor_sha256: self.base_descriptor_sha256.clone(), + base_tier: self.tier, + arch: self.arch, + profile: self.tree.profile, + components: self + .pins + .iter() + .cloned() + .zip(self.archives.iter().cloned()) + .collect(), + producer_revision: producer_revision.to_owned(), + producer_version: env!("CARGO_PKG_VERSION").into(), + ext4_sha256, + verity_sha256, + verity_root_hash, + }; + Ok(MaterializedComponentRootfs { image, lock }) + } + + fn reverify_base(&self, base: &ComponentGraphBase<'_>) -> Result<(), RuntimeLayerStageError> { + let (descriptor, tier, manifest_sha256) = match base { + ComponentGraphBase::Production { + images, + compatibility, + } => { + images.retained_publisher_proof()?; + ( + compatibility.descriptor(), + ImageTrustTier::VerifiedRelease, + Sha256Hex::from_bytes(images.manifest_bytes()), + ) + } + ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } => { + reverify_local_component_base( + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + )?; + ( + compatibility.descriptor(), + ImageTrustTier::LocalDev, + Sha256Hex::from_bytes(manifest_bytes), + ) + } + }; + let descriptor_sha256 = match base { + ComponentGraphBase::Production { compatibility, .. } => { + Sha256Hex::from_bytes(compatibility.descriptor_bytes()) + } + ComponentGraphBase::Development { compatibility, .. } => { + Sha256Hex::from_bytes(compatibility.descriptor_bytes()) + } + }; + if self.tier != tier + || self.arch != descriptor.arch() + || self.tree.profile != descriptor.profile() + || self.tree.base_manifest_sha256 != manifest_sha256 + || self.base_descriptor_sha256 != descriptor_sha256 + || descriptor.image_set_manifest_sha256() != &manifest_sha256 + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + Ok(()) + } + + fn reverify_components( + &self, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], + ) -> Result<(), RuntimeLayerStageError> { + if self.pins.len() != self.archives.len() + || self.pins.len() != applications.len() + components.len() + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + for (pin, archive) in self.pins.iter().zip(&self.archives) { + if let Some(input) = applications + .iter() + .find(|input| &input.descriptor().manifest().reference == pin.reference()) + { + let descriptor = input.descriptor(); + let actual = &descriptor.manifest().application_component.archive; + if &Sha256Hex::from_bytes(descriptor.manifest_bytes()) != pin.manifest_sha256() + || actual.archive_sha256 != archive.sha256 + || actual.archive_size != archive.size + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + input.reverify()?; + } else if let Some(input) = components + .iter() + .find(|input| input.descriptor().manifest().reference() == pin.reference()) + { + let descriptor = input.descriptor(); + let actual = &descriptor.manifest().component().archive; + if descriptor.manifest_sha256() != pin.manifest_sha256() + || actual.archive_sha256 != archive.sha256 + || actual.archive_size != archive.size + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + input.reverify()?; + } else { + return Err(RuntimeLayerStageError::ComponentGraph); + } + } + Ok(()) + } +} + +fn measure_materialized_component( + image: &crate::rootfs::MaterializedExt4, +) -> Result<(Sha256Hex, Sha256Hex, Sha256Hex), RuntimeLayerStageError> { + let root = image + .verity_root_hash + .as_deref() + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let verity_root_hash = + Sha256Hex::new(root).map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let parent = image + .path + .parent() + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + if std::fs::read(parent.join("rootfs.roothash"))? != format!("{root}\n").as_bytes() { + return Err(RuntimeLayerStageError::ComponentGraph); + } + let ext4_sha256 = Sha256Hex::new(&mvm_core::crypto::image_verify::sha256_file(&image.path)?) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let verity_sha256 = Sha256Hex::new(&mvm_core::crypto::image_verify::sha256_file( + &parent.join("rootfs.verity"), + )?) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + Ok((ext4_sha256, verity_sha256, verity_root_hash)) +} diff --git a/crates/mvm-build/src/runtime_layer/entrypoint.rs b/crates/mvm-build/src/runtime_layer/entrypoint.rs new file mode 100644 index 0000000000..73493f6efc --- /dev/null +++ b/crates/mvm-build/src/runtime_layer/entrypoint.rs @@ -0,0 +1,122 @@ +//! Render and retain the v2 recipe's guest entrypoint as sealed filesystem input. + +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use mvm_core::packs::Sha256Hex; + +use super::RuntimeLayerStageError; + +const MARKER: &str = "etc/mvm/entrypoint"; + +pub(super) fn script(argv: &[String]) -> Vec { + let mut text = String::from("#!/bin/sh\nexec"); + for arg in argv { + // POSIX single quotes pass the authored argument literally, including + // shell metacharacters. A quote itself closes and reopens the literal. + text.push_str(" '"); + text.push_str(&arg.replace('\'', "'\\''")); + text.push('\''); + } + text.push('\n'); + text.into_bytes() +} + +fn require_directories(root: &Path) -> Result<(), RuntimeLayerStageError> { + for relative in ["etc", "etc/mvm"] { + let metadata = std::fs::symlink_metadata(root.join(relative)) + .map_err(|_| RuntimeLayerStageError::Entrypoint)?; + if !metadata.file_type().is_dir() || metadata.permissions().mode() & 0o001 == 0 { + return Err(RuntimeLayerStageError::Entrypoint); + } + } + Ok(()) +} + +pub(super) fn write_script( + root: &Path, + argv: &[String], +) -> Result { + require_directories(root)?; + let path = root.join(MARKER); + if std::fs::symlink_metadata(&path).is_ok() { + return Err(RuntimeLayerStageError::Entrypoint); + } + let bytes = script(argv); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + file.write_all(&bytes)?; + file.set_permissions(std::fs::Permissions::from_mode(0o555))?; + file.sync_all()?; + Ok(Sha256Hex::from_bytes(&bytes)) +} + +pub(super) fn verify_script(root: &Path, digest: &Sha256Hex) -> Result<(), RuntimeLayerStageError> { + require_directories(root)?; + let path = root.join(MARKER); + let metadata = + std::fs::symlink_metadata(&path).map_err(|_| RuntimeLayerStageError::Entrypoint)?; + if !metadata.file_type().is_file() + || metadata.permissions().mode() & 0o7777 != 0o555 + || Sha256Hex::from_bytes(&std::fs::read(&path)?) != *digest + { + return Err(RuntimeLayerStageError::Entrypoint); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_arguments_are_literal_shell_words() { + let command = script(&[ + "/usr/bin/printf".into(), + "%s".into(), + "a'b $HOME; $(false)".into(), + ]); + assert_eq!( + command, + b"#!/bin/sh\nexec '/usr/bin/printf' '%s' 'a'\\''b $HOME; $(false)'\n" + ); + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(String::from_utf8(command).unwrap()) + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output.stderr); + assert_eq!(output.stdout, b"a'b $HOME; $(false)"); + } + + #[test] + fn occupied_or_changed_host_owned_marker_is_refused() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("etc/mvm")).unwrap(); + let argv = ["/bin/true".into()]; + let digest = write_script(root.path(), &argv).unwrap(); + verify_script(root.path(), &digest).unwrap(); + assert!(matches!( + write_script(root.path(), &argv), + Err(RuntimeLayerStageError::Entrypoint) + )); + let marker = root.path().join(MARKER); + std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::write(&marker, b"tampered").unwrap(); + assert!(matches!( + verify_script(root.path(), &digest), + Err(RuntimeLayerStageError::Entrypoint) + )); + std::fs::remove_file(&marker).unwrap(); + std::fs::remove_dir(root.path().join("etc/mvm")).unwrap(); + std::os::unix::fs::symlink(root.path(), root.path().join("etc/mvm")).unwrap(); + assert!(matches!( + write_script(root.path(), &argv), + Err(RuntimeLayerStageError::Entrypoint) + )); + } +} diff --git a/crates/mvm-bundler/src/export.rs b/crates/mvm-bundler/src/export.rs index ce00521ebc..8890aebd23 100644 --- a/crates/mvm-bundler/src/export.rs +++ b/crates/mvm-bundler/src/export.rs @@ -186,6 +186,14 @@ fn stage_bundle_at( || (inputs.cli_boot_assets.is_some() && inputs.boot_assets.is_none()), "canonical Linux proof requires one selected CLI boot closure" ); + anyhow::ensure!( + inputs.local_development_base_proof.is_none() + || (inputs.canonical_linux_proof.is_none() + && inputs.local_build + && inputs.cli_boot_assets.is_some() + && inputs.local_composition.is_some()), + "local development base requires local composition, CLI runtime and no canonical proof" + ); let mut contents = BundleContents::default(); contents.push_file(KERNEL_NAME, ArtifactRole::Kernel, "kernel", inputs.vmlinux)?; @@ -207,7 +215,11 @@ fn stage_bundle_at( inputs.boot_assets.is_none(), "cannot mix CLI and legacy boot assets" ); - contents.push_cli_boot_assets(boot, inputs.arch_label)?; + contents.push_cli_boot_assets( + boot, + inputs.arch_label, + inputs.local_development_base_proof.as_ref(), + )?; } if let Some(proof) = &inputs.canonical_linux_proof { let boot = inputs @@ -453,6 +465,7 @@ impl BundleContents { &mut self, boot: &crate::CliBootAssetsInputs<'_>, arch: &str, + local: Option<&mvm_core::plan::bundle::LocalDevelopmentBaseProof>, ) -> Result<()> { use mvm_core::arch::GuestArch; use mvm_core::image_set::ImageSetManifest; @@ -474,15 +487,34 @@ impl BundleContents { other => anyhow::bail!("unknown CLI boot architecture {other}"), }; let manifest: ImageSetManifest = serde_json::from_slice(boot.manifest_bytes)?; - let member = select_cli_boot_kernel(&manifest, arch)?; - let [kernel] = member.artifacts.as_slice() else { - anyhow::bail!("CLI boot kernel must declare one artifact"); - }; let bundled = self .artifacts .iter() .find(|artifact| artifact.role == ArtifactRole::Kernel) .context("CLI boot kernel missing")?; + let member = if let Some(proof) = local { + anyhow::ensure!( + proof.manifest_artifact == BOOT_MANIFEST_NAME + && std::fs::canonicalize(&proof.composition_tar_path)? + == Path::new(&proof.composition_tar_path), + "local base source path is not canonical or manifest identity differs" + ); + mvm_core::plan::bundle::verify_local_development_source(proof) + .context("local composition source differs from its proof")?; + mvm_core::plan::bundle::select_local_development_base( + &manifest, + arch, + &bundled.sha256, + bundled.size_bytes, + proof, + )? + .0 + } else { + select_cli_boot_kernel(&manifest, arch)? + }; + let [kernel] = member.artifacts.as_slice() else { + anyhow::bail!("CLI boot kernel must declare one artifact"); + }; anyhow::ensure!( bundled.sha256 == kernel.sha256.as_str() && bundled.size_bytes == kernel.size, "CLI boot kernel differs from the original Linux image set" @@ -651,6 +683,9 @@ fn declarations_for( }, )); } + if let Some(proof) = &inputs.local_development_base_proof { + members.push(BundleMember::LocalDevelopmentBaseProof(proof.clone())); + } if let Some(composition) = &inputs.local_composition { members.push(BundleMember::LocalComposition(composition.clone())); } @@ -686,7 +721,8 @@ fn declarations_for( /// bundle stays readable by the oldest reader that understands it: 3 for /// plain declarations, 4 for historical boot assets, 5 for an environment /// descriptor, 6 for a portable CLI-runtime closure, and 7 for canonical -/// Linux publisher/revocation evidence; 8 binds local composition inputs. +/// Linux publisher/revocation evidence; 8 binds local composition inputs and 9 +/// binds the distinct same-host development base source. fn schema_for(members: &[BundleMember]) -> u32 { members .iter() @@ -880,6 +916,36 @@ mod tests { ); } + #[test] + fn local_source_proof_bumps_only_its_own_bundle_to_schema_nine() { + let composition = + BundleMember::LocalComposition(mvm_core::plan::bundle::LocalComposition { + lock_artifact: "composition-lock.json".into(), + layers: Vec::new(), + }); + let local = BundleMember::LocalDevelopmentBaseProof( + mvm_core::plan::bundle::LocalDevelopmentBaseProof { + manifest_artifact: BOOT_MANIFEST_NAME.into(), + composition_tar_sha256: "a".repeat(64), + composition_tar_size: 16, + composition_tar_path: "/local/base.tar.gz".into(), + }, + ); + assert_eq!(schema_for(std::slice::from_ref(&composition)), 8); + assert_eq!(schema_for(&[composition, local]), 9); + assert_eq!( + schema_for(&[BundleMember::CanonicalLinuxProof( + mvm_core::plan::bundle::CanonicalLinuxProof { + manifest_artifact: BOOT_MANIFEST_NAME.into(), + signature_bundle_artifact: LINUX_SIGNATURE_NAME.into(), + revocation_document_artifact: REVOCATIONS_NAME.into(), + revocation_signature_bundle_artifact: REVOCATIONS_SIGNATURE_NAME.into(), + }, + )]), + 7 + ); + } + #[test] fn sidecar_resolves_beside_the_rootfs() { assert_eq!( diff --git a/crates/mvm-bundler/src/export/canonical_tests.rs b/crates/mvm-bundler/src/export/canonical_tests.rs index cd4a2bde72..b49064761e 100644 --- a/crates/mvm-bundler/src/export/canonical_tests.rs +++ b/crates/mvm-bundler/src/export/canonical_tests.rs @@ -132,6 +132,7 @@ impl Fixture { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; Self { directory, diff --git a/crates/mvm-bundler/src/inputs.rs b/crates/mvm-bundler/src/inputs.rs index 971b2b7663..7bb78a3bc3 100644 --- a/crates/mvm-bundler/src/inputs.rs +++ b/crates/mvm-bundler/src/inputs.rs @@ -53,6 +53,8 @@ pub struct BundleExportInputs<'a> { pub boot_assets: Option>, pub cli_boot_assets: Option>, pub canonical_linux_proof: Option>, + /// Same-host, measured source evidence; never a canonical publisher proof. + pub local_development_base_proof: Option, /// Path to the kernel image. pub vmlinux: &'a str, /// Path to the initrd, when the template boots with one. @@ -105,6 +107,7 @@ impl<'a> BundleExportInputs<'a> { boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, vmlinux, initrd: None, rootfs, diff --git a/crates/mvm-bundler/tests/boot_assets.rs b/crates/mvm-bundler/tests/boot_assets.rs index 271dc00d17..accd09876e 100644 --- a/crates/mvm-bundler/tests/boot_assets.rs +++ b/crates/mvm-bundler/tests/boot_assets.rs @@ -48,6 +48,7 @@ fn cli_boot_export_preserves_linux_identity_and_refuses_incomplete_or_mixed_memb overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; // Structural sealing only: synthetic runtime inputs must not pass release verification. let paths: Vec<_> = [ diff --git a/crates/mvm-cli/Cargo.toml b/crates/mvm-cli/Cargo.toml index bcd9378f5f..3e169f8ca4 100644 --- a/crates/mvm-cli/Cargo.toml +++ b/crates/mvm-cli/Cargo.toml @@ -175,6 +175,12 @@ manifest-verify = [ "mvm-client/manifest-verify", "mvm-build/manifest-verify", ] +pr-test-guest-runtime = [ + "mvm-core/pr-test-guest-runtime", + "mvm-client/pr-test-guest-runtime", + "mvm-hostd/pr-test-guest-runtime", +] +pr-test-synthetic-base = ["mvm-build/pr-test-synthetic-base"] template-registry-s3 = ["mvm-runtime/template-registry-s3"] # Forwards to `mvm-runtime/test-support` + `mvm-client/test-support`: the # hermetic in-memory `MockBackend` and the `--hypervisor mock` fast paths in diff --git a/crates/mvm-cli/src/commands/bundle/export.rs b/crates/mvm-cli/src/commands/bundle/export.rs index c0a6e4aa45..fd1ef8ec5b 100644 --- a/crates/mvm-cli/src/commands/bundle/export.rs +++ b/crates/mvm-cli/src/commands/bundle/export.rs @@ -324,6 +324,7 @@ pub(in crate::commands) fn stage_template( boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, supplemental_artifacts: &[], local_composition: None, out: &export.out, diff --git a/crates/mvm-cli/src/commands/env/builder_vm/default_microvm.rs b/crates/mvm-cli/src/commands/env/builder_vm/default_microvm.rs index 873f53851e..010b609cb4 100644 --- a/crates/mvm-cli/src/commands/env/builder_vm/default_microvm.rs +++ b/crates/mvm-cli/src/commands/env/builder_vm/default_microvm.rs @@ -986,6 +986,22 @@ mod pair_default_image_tests { ); std::fs::write(&root, original).unwrap(); ensure().unwrap(); + if variant == DefaultMicrovmVariant::Prod { + let manifest: mvm_core::image_set::ImageSetManifest = + serde_json::from_slice(&std::fs::read(&proof).unwrap()).unwrap(); + let tar = manifest + .members + .iter() + .flat_map(|member| &member.artifacts) + .find(|artifact| artifact.format == mvm_core::image_set::ArtifactFormat::TarGz) + .expect("sealed local set declares a composition tar"); + let tar_path = proof.parent().unwrap().join(tar.name.as_str()); + let original = std::fs::read(&tar_path).expect("retained tar survives eviction"); + std::fs::write(&tar_path, b"tampered").unwrap(); + assert!(ensure().is_err(), "changed retained tar must refuse"); + std::fs::write(&tar_path, original).unwrap(); + ensure().unwrap(); + } write( &pair.images.root().join("images/default-tenant/image.nix"), b"# changed source\n", diff --git a/crates/mvm-cli/src/commands/env/builder_vm/test_pair.rs b/crates/mvm-cli/src/commands/env/builder_vm/test_pair.rs index b4a0bc096c..1c2ca42561 100644 --- a/crates/mvm-cli/src/commands/env/builder_vm/test_pair.rs +++ b/crates/mvm-cli/src/commands/env/builder_vm/test_pair.rs @@ -289,6 +289,37 @@ impl Pair { self.publish_with_base_contract(role, attr, members, None, mvm_root) } + fn linux_composition_tar() -> Vec { + let mut archive = tar::Builder::new(Vec::new()); + for path in ["etc/", "etc/mvm/"] { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_mode(0o755); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(0); + header.set_cksum(); + archive + .append_data(&mut header, path, std::io::empty()) + .unwrap(); + } + let mut header = tar::Header::new_gnu(); + header.set_mode(0o444); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(2); + header.set_cksum(); + archive + .append_data(&mut header, "etc/mvm/base-image-contract", &b"1\n"[..]) + .unwrap(); + let mut compressed = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut compressed, &archive.into_inner().unwrap()).unwrap(); + compressed.finish().unwrap() + } + pub(crate) fn publish_linux_workload( &self, attr: &str, @@ -340,6 +371,11 @@ impl Pair { ]; if attr == "default" { artifacts.extend([ + TestArtifact { + name: "rootfs.tar.gz", + bytes: Self::linux_composition_tar(), + format: "tar_gz", + }, TestArtifact { name: "rootfs.verity", bytes: b"verity\n".to_vec(), diff --git a/crates/mvm-cli/src/commands/machine/create_spec.rs b/crates/mvm-cli/src/commands/machine/create_spec.rs new file mode 100644 index 0000000000..1606285223 --- /dev/null +++ b/crates/mvm-cli/src/commands/machine/create_spec.rs @@ -0,0 +1,309 @@ +//! Construction of a persistent machine spec from CLI and manifest inputs. + +use super::*; + +#[derive(ClapArgs, Debug, Clone, Default)] +pub(in crate::commands) struct MachinePackBaseArgs { + /// Local signed image-set manifest for a development composition. + #[arg(id = "pack_base_manifest", long = "pack-base-manifest", value_name = "PATH", requires_all = ["pack", "pack_base_kernel", "pack_base_tar"], hide = true)] + pub manifest: Option, + /// Kernel named by the local image-set manifest. + #[arg(id = "pack_base_kernel", long = "pack-base-kernel", value_name = "PATH", requires_all = ["pack", "pack_base_manifest", "pack_base_tar"], hide = true)] + pub kernel: Option, + /// Composition tar named by the local image-set manifest. + #[arg(id = "pack_base_tar", long = "pack-base-tar", value_name = "PATH", requires_all = ["pack", "pack_base_manifest", "pack_base_kernel"], hide = true)] + pub tar: Option, + /// PR-only runtime quartet, never a release input. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(id = "pack_pr_test_runtime_dir", long = "pack-pr-test-runtime-dir", value_name = "PATH", requires_all = ["pack_base_manifest", "pack_pr_test_pull_request"], hide = true)] + pub pr_runtime_dir: Option, + /// Pull request whose workflow signed the test runtime. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(id = "pack_pr_test_pull_request", long = "pack-pr-test-pull-request", value_name = "NUMBER", requires_all = ["pack_base_manifest", "pack_pr_test_runtime_dir"], hide = true)] + pub pr_pull_request: Option, +} + +impl MachinePackBaseArgs { + pub(super) fn selected(&self) -> Result> { + use mvm_client::machine_spec::{LocalPackBaseSource, LocalPackPrRuntime}; + + #[cfg(feature = "pr-test-guest-runtime")] + let pr_runtime = match (&self.pr_runtime_dir, self.pr_pull_request) { + (Some(directory), Some(pull_request)) => Some(LocalPackPrRuntime { + directory: directory.clone(), + pull_request, + }), + (None, None) => None, + _ => anyhow::bail!("PR test runtime requires its directory and pull request"), + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let pr_runtime: Option = None; + match (&self.manifest, &self.kernel, &self.tar) { + (Some(manifest), Some(kernel), Some(tar)) => Ok(Some( + LocalPackBaseSource::from_selected(manifest, kernel, tar, pr_runtime)?, + )), + (None, None, None) if pr_runtime.is_none() => Ok(None), + _ => anyhow::bail!("local pack base requires a manifest, kernel and composition tar"), + } + } +} + +/// Inputs shared by `machine create` and `machine start --image`. +pub(super) struct MachineSpecInputs<'a> { + pub name: &'a str, + pub image: Option<&'a str>, + pub local_pack: Option<&'a mvm_client::machine_spec::LocalPackSource>, + pub net: bool, + pub allow_host: &'a [String], + pub peer: &'a [String], + pub routes: &'a [EgressRoute], + pub gpu: bool, + pub gpu_device: Option, + pub ai: Option<&'a mvm_core::network_policy::AiPolicy>, + pub cpus: Option, + pub cpu_limit: Option, + pub timeout: Option, + pub grants_file: Option<&'a Path>, + pub memory: Option<&'a str>, + pub mem_initial: Option<&'a str>, + pub profile: Option, + pub workflow: Option<&'a ManifestMachineWorkflow>, + pub volumes: &'a [String], + pub init: &'a [String], + pub policy: &'a [String], + pub project: Option<&'a mvm_client::policy_profiles::ProjectPolicy>, +} + +pub(super) fn build_machine_spec(inputs: MachineSpecInputs<'_>) -> Result { + validate_machine_name(inputs.name)?; + let workflow = inputs.workflow; + anyhow::ensure!( + inputs.local_pack.is_none() || (inputs.image.is_none() && workflow.is_none()), + "local pack cannot be combined with an image or manifest source" + ); + let image = match (inputs.image, workflow, inputs.local_pack) { + (Some(image), _, None) => Some(image.to_string()), + (None, Some(workflow), None) => Some(workflow.image.clone()), + (None, None, Some(_)) => None, + (None, None, None) => { + bail!( + "machine {} requires --image, --manifest or --pack", + inputs.name + ) + } + _ => bail!("local pack cannot be combined with another machine source"), + }; + let net = inputs.net || workflow.is_some_and(|workflow| workflow.net); + let gpu_device = inputs + .gpu_device + .or_else(|| workflow.and_then(|workflow| workflow.gpu_device)); + let gpu = inputs.gpu || gpu_device.is_some() || workflow.is_some_and(|workflow| workflow.gpu); + let cpus = inputs + .cpus + .or_else(|| workflow.map(|workflow| workflow.cpus)) + .unwrap_or(2); + let memory = inputs + .memory + .map(String::from) + .or_else(|| workflow.map(|workflow| workflow.mem.clone())) + .unwrap_or_else(|| "512M".to_string()); + // The manifest's `[network] allow_hosts` reaches the spec through the + // policy, the same way it reaches `run` and `machine run`. + let policy = create_policy::machine_policy(create_policy::MachinePolicyInputs { + policies: inputs.policy, + project: inputs.project, + allow_host: inputs.allow_host, + net, + cpu_limit: inputs.cpu_limit, + timeout: inputs.timeout, + cpus, + memory_mib: u64::from(mvm_core::util::parse_human_size(&memory)?), + })?; + let allow_host = policy.allow_host; + let routes = mvm_client::admission::run_routes::resolve_run_routes(&[], inputs.routes)?; + let allow_host = routes.with_allow_host(&allow_host); + let ai = inputs + .ai + .or(workflow.and_then(|workflow| workflow.ai.as_ref())); + // Resolving grants also settles the egress policy, so validating it + // here validates the same policy the machine will actually boot under. + let config = mvm_core::user_config::load(None); + let resolved = super::super::shared::resolve_policy_run_grants( + super::super::shared::GrantInputs { + cpu_limit_millicores: policy.cpu_limit, + timeout_secs: policy.timeout, + allow_host: &allow_host, + peer: inputs.peer, + net, + network_preset: None, + grants_file: inputs.grants_file, + manifest: workflow.map(|workflow| &workflow.grants), + config: &config, + ai, + }, + policy.policy.as_ref(), + )?; + if cpus == 0 { + bail!("machine CPUs must be >= 1"); + } + let mem_initial = inputs + .mem_initial + .map(String::from) + .or_else(|| workflow.and_then(|workflow| workflow.mem_initial.clone())); + let _ = validate_machine_memory(&memory, mem_initial.as_deref())?; + let profile = inputs.profile.unwrap_or(RunProfile::Dev); + anyhow::ensure!( + inputs.local_pack.is_none() || profile == RunProfile::Dev, + "local pack machines require the dev profile" + ); + enforce_volume_profile(profile, inputs.volumes)?; + let profile_name = run_profile_name(profile).to_string(); + enforce_dev_init_profile(&profile_name, inputs.init)?; + Ok(MachineSpec { + schema_version: MACHINE_SPEC_SCHEMA_VERSION, + name: inputs.name.to_string(), + image, + manifest: None, + deployment: None, + resolved_digest: None, + runtime_pack: false, + registry_pack_image: None, + local_pack: inputs.local_pack.cloned(), + net, + allow_host, + peer: inputs.peer.to_vec(), + routes: routes.routes, + ai: ai.cloned(), + ports: Vec::new(), + cpus, + memory, + mem_initial, + profile: profile_name, + volumes: inputs.volumes.to_vec(), + init: inputs.init.to_vec(), + agent_verb: Vec::new(), + caller_commitment: None, + workload_dir: None, + created_at: Some(mvm_core::time::utc_now()), + last_started_at: None, + health_check: None, + grants: resolved.plan_grants, + gpu, + gpu_device, + }) +} + +impl MachineCreateArgs { + pub(super) fn into_spec(self) -> Result { + self.into_spec_with(crate::commands::vm::prepare_local_pack_data) + } + + pub(super) fn into_spec_with( + self, + prepare: impl FnOnce( + &Path, + Option<&mvm_client::machine_spec::LocalPackBaseSource>, + ) -> Result, + ) -> Result { + let name = match self.name { + Some(name) => { + validate_machine_name(&name)?; + name + } + None => auto_machine_name(), + }; + let pack_base = self.pack_base.selected()?; + anyhow::ensure!( + self.pack.is_some() || pack_base.is_none(), + "local base selection requires --pack" + ); + let manifest_source = if self.pack.is_some() { + None + } else { + match (self.image.is_none(), self.manifest.as_deref()) { + (_, Some(arg)) => Some(load_machine_manifest_source(Path::new(arg))?), + (true, None) => Some(load_machine_manifest_source(Path::new(".")).with_context( + || "machine create requires --image, --pack or an image-backed mvm.toml in the current directory", + )?), + (false, None) => None, + } + }; + let local_pack = self + .pack + .as_deref() + .map(|recipe| -> Result<_> { + let archive = prepare(recipe, pack_base.as_ref())?; + let sha = mvm_core::packs::Sha256Hex::new( + mvm_core::crypto::image_verify::sha256_file(&archive)?, + )?; + let mut source = + mvm_client::machine_spec::LocalPackSource::from_prepared(recipe, sha)?; + source.base = pack_base; + Ok(source) + }) + .transpose()?; + let workflow = manifest_source.as_ref().map(|source| &source.workflow); + let init = workflow + .map(|workflow| workflow.init.clone()) + .unwrap_or_default(); + let volumes = match manifest_source.as_ref() { + Some(source) => source + .workflow + .volumes + .iter() + .map(|spec| absolutize_manifest_volume_spec(spec, &source.base_dir)) + .collect::>>()?, + None => Vec::new(), + }; + build_machine_spec(MachineSpecInputs { + name: &name, + image: self.image.as_deref(), + local_pack: local_pack.as_ref(), + net: self.net, + allow_host: &self.allow_host, + peer: &self.peer, + routes: manifest_source + .as_ref() + .map(|source| source.routes.as_slice()) + .unwrap_or_default(), + gpu: self.gpu, + gpu_device: self.gpu_device, + ai: None, + cpus: self.cpus, + cpu_limit: self.cpu_limit, + timeout: self.timeout, + grants_file: self.grants_file.as_deref(), + memory: self.memory.as_deref(), + mem_initial: self.mem_initial.as_deref(), + profile: self.profile, + workflow, + volumes: &volumes, + init: &init, + policy: &self.policy, + project: manifest_source.as_ref().map(|source| &source.project), + }) + } +} + +pub(super) fn run_profile_name(profile: RunProfile) -> &'static str { + profile.as_str() +} + +fn profile_allows_dev_init(profile: RunProfile) -> bool { + profile.grants().dev_guest +} + +/// A stored spec's unrecognised profile must refuse before guest init. +pub(super) fn enforce_dev_init_profile(profile: &str, init: &[String]) -> Result<()> { + if init.is_empty() { + return Ok(()); + } + let parsed = RunProfile::from_name(profile) + .ok_or_else(|| anyhow!("machine spec carries an unrecognised profile {profile:?}"))?; + if !profile_allows_dev_init(parsed) { + bail!( + "machine dev.init requires a dev-capable profile; use --profile dev or --profile permissive" + ); + } + Ok(()) +} diff --git a/crates/mvm-cli/src/commands/machine/lifecycle.rs b/crates/mvm-cli/src/commands/machine/lifecycle.rs index 6f2cb4718a..af63591f7e 100644 --- a/crates/mvm-cli/src/commands/machine/lifecycle.rs +++ b/crates/mvm-cli/src/commands/machine/lifecycle.rs @@ -93,6 +93,7 @@ pub(super) fn resolve_start_spec(args: &MachineStartArgs) -> Result<(MachineSpec let desired = build_machine_spec(MachineSpecInputs { name: &args.name, image: args.create_flags.image.as_deref(), + local_pack: None, net: args.create_flags.net, allow_host: &args.create_flags.allow_host, peer: &args.create_flags.peer, @@ -168,16 +169,30 @@ pub(super) fn start_machine(args: MachineStartArgs) -> Result<()> { enforce_persisted_volume_profile(&spec.profile, &spec.volumes)?; let effective_hypervisor = super::runtime::effective_hypervisor(args.hypervisor.as_deref()); let receipt_input = machine_start_receipt_input(&spec, &effective_hypervisor)?; - let started = mvm_client::launch::machine_start::start_machine_spec( - &spec, - &CliStartHost::for_spec(&spec, args.kernel_pin.is_some()), - mvm_client::launch::machine_start::MachineStartParams { - hypervisor: &effective_hypervisor, - has_ad_hoc_argv: args.has_ad_hoc_argv, - mailbox: args.mailbox, - }, - )?; + let started = if spec.local_pack.is_some() { + start_local_pack(&spec, &args, &effective_hypervisor)? + } else { + mvm_client::launch::machine_start::start_machine_spec( + &spec, + &CliStartHost::for_spec(&spec, args.kernel_pin.is_some()), + mvm_client::launch::machine_start::MachineStartParams { + hypervisor: &effective_hypervisor, + has_ad_hoc_argv: args.has_ad_hoc_argv, + mailbox: args.mailbox, + }, + )? + }; let boot_digest = started.resolved_digest; + if spec.local_pack.is_some() + && let Err(err) = mvm_client::guest::start_local_pack_entrypoint(&spec.name) + .with_context(|| format!("starting the sealed entrypoint for {:?}", spec.name)) + { + return Err(stop_failed_local_pack_start( + &spec.name, + &effective_hypervisor, + err, + )); + } if !spec.init.is_empty() && let Err(err) = run_machine_init_commands(&spec.name, &spec.init) { @@ -213,6 +228,87 @@ pub(super) fn start_machine(args: MachineStartArgs) -> Result<()> { Ok(()) } +#[tracing::instrument(level = "trace", skip_all)] +fn start_local_pack( + spec: &MachineSpec, + args: &MachineStartArgs, + hypervisor: &str, +) -> Result { + anyhow::ensure!( + args.kernel_pin.is_none() && !args.has_ad_hoc_argv && args.mailbox.is_none(), + "a named local pack cannot replace its signed kernel or add a command or mailbox" + ); + eprintln!("[mvm] named pack warm acceleration unavailable; verifying inputs for a cold start"); + let pin = spec + .local_pack + .as_ref() + .context("named machine has no local pack source")?; + pin.verify_recipe()?; + let archive = crate::commands::vm::prepare_local_pack_data(&pin.recipe, pin.base.as_ref())?; + anyhow::ensure!( + mvm_core::crypto::image_verify::sha256_file(&archive)? == pin.archive_sha256.as_str(), + "local pack composition changed since machine create; recreate it to select new bytes" + ); + pin.verify_recipe()?; + mvm_client::launch::machine_start::prepare_named_pack_start(&spec.name, hypervisor)?; + let mut staged = + mvm_client::launch::transient_composition::TransientComposition::stage_for_machine( + &archive, &spec.name, + )?; + let (result, admitted) = { + let boot = staged.consume()?; + let result = mvm_client::launch::machine_start::start_local_pack_machine( + spec, + &boot, + &CliStartHost::for_spec(spec, false), + mvm_client::launch::machine_start::MachineStartParams { + hypervisor, + has_ad_hoc_argv: false, + mailbox: None, + }, + ); + (result, boot.was_admitted()) + }; + match result { + Ok(started) => { + if let Err(error) = staged.retain_for_running_machine() { + let retained = staged.retain_on_error(); + return Err(stop_failed_local_pack_start( + &spec.name, + hypervisor, + anyhow::anyhow!( + "named composition booted but its source custody failed: {error:#}; retained={retained:?}" + ), + )); + } + Ok(started) + } + Err(error) => { + if admitted && let Some(path) = staged.retain_on_error() { + eprintln!( + "[mvm] retained named composition evidence at {} after failed admission or boot", + path.display() + ); + } + Err(error) + } + } +} + +fn stop_failed_local_pack_start( + name: &str, + hypervisor: &str, + error: anyhow::Error, +) -> anyhow::Error { + match mvm_client::backend_stop_by_name(hypervisor, name) { + Ok(()) => error, + Err(stop_error) => error.context(format!( + "could not confirm stop of named composition {name:?}: {stop_error:#}; \ + its private boot bytes remain retained for recovery" + )), + } +} + /// How the CLI supplies a machine start with what differs by process: it may /// build the workload kernel through the builder VM, and it prepares volumes /// through its mount cache. diff --git a/crates/mvm-cli/src/commands/machine/mod.rs b/crates/mvm-cli/src/commands/machine/mod.rs index 4c099b8e78..160f19f23b 100644 --- a/crates/mvm-cli/src/commands/machine/mod.rs +++ b/crates/mvm-cli/src/commands/machine/mod.rs @@ -16,6 +16,7 @@ mod checkpoint; mod create_policy; +pub(in crate::commands) mod create_spec; pub(crate) mod lifecycle; mod list; mod mailbox; @@ -61,6 +62,10 @@ use super::vm::host_signer::{host_signer_id, load_or_init}; use super::vm::{console, down}; use crate::ui; use checkpoint::{ForkVmFullMachineInput, fork_vm_full_machine}; +use create_spec::{ + MachinePackBaseArgs, MachineSpecInputs, build_machine_spec, enforce_dev_init_profile, + run_profile_name, +}; use lifecycle::{ quarantine_machine, replace_machine, run_exec_with_journal, run_restart, run_start, shell_machine, stop_machine, @@ -76,6 +81,7 @@ use receipt::{ }; pub(in crate::commands) use runtime::boot_persistent_by_name; pub(in crate::commands) use runtime::run_dispatch; +pub(in crate::commands) use runtime::run_dispatch_composed; use spec_ops::{ create_machine, inspect_machine, persistent_workload_dir_for_run, remove_machine, run_reconfigure, @@ -722,6 +728,7 @@ fn machine_run_spec( resolved_digest: None, runtime_pack: args.run.runtime_pack, registry_pack_image: args.run.registry_pack_image.clone(), + local_pack: None, net, allow_host, peer: Vec::new(), @@ -766,6 +773,11 @@ pub(in crate::commands) struct MachineCreateArgs { /// OCI image reference to boot when the machine lifecycle starts. #[arg(long, value_name = "REF", conflicts_with = "manifest")] pub image: Option, + /// Prepare a local pack as machine data without enrolling its host signer. + #[arg(long, value_name = "PACK.TOML", conflicts_with_all = ["image", "manifest"], hide = true)] + pub pack: Option, + #[command(flatten)] + pub(super) pack_base: MachinePackBaseArgs, /// Enable dev-tier outbound networking for this machine. #[arg(long)] pub net: bool, @@ -1144,241 +1156,8 @@ pub(in crate::commands) struct MachineRestoreArgs { pub json: bool, } -/// Inputs required to build a persistent [`MachineSpec`]. Extracted so both -/// `machine create` and `machine start --image ...` share one -/// validation/construction path. -struct MachineSpecInputs<'a> { - name: &'a str, - image: Option<&'a str>, - net: bool, - allow_host: &'a [String], - peer: &'a [String], - routes: &'a [EgressRoute], - /// `--gpu` (or the manifest's `gpu = true`): the GPU remoting plane. - gpu: bool, - /// Explicit CLI ordinal, or the manifest's `gpu_device` selection. - gpu_device: Option, - ai: Option<&'a mvm_core::network_policy::AiPolicy>, - cpus: Option, - cpu_limit: Option, - timeout: Option, - grants_file: Option<&'a Path>, - memory: Option<&'a str>, - mem_initial: Option<&'a str>, - profile: Option, - workflow: Option<&'a ManifestMachineWorkflow>, - volumes: &'a [String], - init: &'a [String], - /// `--policy NAME|PATH`, lowest precedence first. - policy: &'a [String], - /// The manifest's contribution to the policy, when sourced from one. - project: Option<&'a mvm_client::policy_profiles::ProjectPolicy>, -} - -fn build_machine_spec(inputs: MachineSpecInputs<'_>) -> Result { - validate_machine_name(inputs.name)?; - let workflow = inputs.workflow; - let image = match (inputs.image, workflow) { - (Some(image), _) => image.to_string(), - (None, Some(workflow)) => workflow.image.clone(), - (None, None) => { - bail!( - "machine {} requires either --image or --manifest ", - inputs.name - ) - } - }; - let net = inputs.net || workflow.is_some_and(|workflow| workflow.net); - let gpu_device = inputs - .gpu_device - .or_else(|| workflow.and_then(|workflow| workflow.gpu_device)); - let gpu = inputs.gpu || gpu_device.is_some() || workflow.is_some_and(|workflow| workflow.gpu); - let cpus = inputs - .cpus - .or_else(|| workflow.map(|workflow| workflow.cpus)) - .unwrap_or(2); - let memory = inputs - .memory - .map(String::from) - .or_else(|| workflow.map(|workflow| workflow.mem.clone())) - .unwrap_or_else(|| "512M".to_string()); - // The manifest's `[network] allow_hosts` reaches the spec through the - // policy, the same way it reaches `run` and `machine run`. - let policy = create_policy::machine_policy(create_policy::MachinePolicyInputs { - policies: inputs.policy, - project: inputs.project, - allow_host: inputs.allow_host, - net, - cpu_limit: inputs.cpu_limit, - timeout: inputs.timeout, - cpus, - memory_mib: u64::from(mvm_core::util::parse_human_size(&memory)?), - })?; - let allow_host = policy.allow_host; - let routes = mvm_client::admission::run_routes::resolve_run_routes(&[], inputs.routes)?; - let allow_host = routes.with_allow_host(&allow_host); - let ai = inputs - .ai - .or(workflow.and_then(|workflow| workflow.ai.as_ref())); - // Resolving grants also settles the egress policy, so validating it - // here validates the same policy the machine will actually boot under. - let config = mvm_core::user_config::load(None); - let resolved = super::shared::resolve_policy_run_grants( - super::shared::GrantInputs { - cpu_limit_millicores: policy.cpu_limit, - timeout_secs: policy.timeout, - allow_host: &allow_host, - peer: inputs.peer, - net, - network_preset: None, - grants_file: inputs.grants_file, - manifest: workflow.map(|workflow| &workflow.grants), - config: &config, - ai, - }, - policy.policy.as_ref(), - )?; - if cpus == 0 { - bail!("machine CPUs must be >= 1"); - } - let mem_initial = inputs - .mem_initial - .map(String::from) - .or_else(|| workflow.and_then(|workflow| workflow.mem_initial.clone())); - let _ = validate_machine_memory(&memory, mem_initial.as_deref())?; - // CLI-created machines are development workloads unless the caller - // explicitly opts into a stricter profile. The daemon is the only - // producer that should supply a non-dev production profile by policy. - let profile = inputs.profile.unwrap_or(RunProfile::Dev); - enforce_volume_profile(profile, inputs.volumes)?; - let profile_name = run_profile_name(profile).to_string(); - enforce_dev_init_profile(&profile_name, inputs.init)?; - Ok(MachineSpec { - schema_version: MACHINE_SPEC_SCHEMA_VERSION, - name: inputs.name.to_string(), - image: Some(image), - manifest: None, - deployment: None, - resolved_digest: None, - runtime_pack: false, - registry_pack_image: None, - net, - allow_host, - peer: inputs.peer.to_vec(), - routes: routes.routes, - ai: ai.cloned(), - ports: Vec::new(), - cpus, - memory, - mem_initial, - profile: profile_name, - volumes: inputs.volumes.to_vec(), - init: inputs.init.to_vec(), - agent_verb: Vec::new(), - caller_commitment: None, - workload_dir: None, - created_at: Some(mvm_core::time::utc_now()), - last_started_at: None, - health_check: None, - grants: resolved.plan_grants, - gpu, - gpu_device, - }) -} - -impl MachineCreateArgs { - fn into_spec(self) -> Result { - // Auto-generate a name when `NAME` is omitted, mirroring `machine run - // -d` (`create_machine` prints the chosen name). - let name = match self.name { - Some(name) => { - validate_machine_name(&name)?; - name - } - None => auto_machine_name(), - }; - let manifest_source = match (self.image.is_none(), self.manifest.as_deref()) { - (_, Some(arg)) => Some(load_machine_manifest_source(Path::new(arg))?), - (true, None) => Some(load_machine_manifest_source(Path::new(".")).with_context( - || { - "machine create requires --image or an image-backed mvm.toml in the current directory" - }, - )?), - (false, None) => None, - }; - let workflow = manifest_source.as_ref().map(|source| &source.workflow); - let init = workflow - .map(|workflow| workflow.init.clone()) - .unwrap_or_default(); - let volumes = match manifest_source.as_ref() { - Some(source) => source - .workflow - .volumes - .iter() - .map(|spec| absolutize_manifest_volume_spec(spec, &source.base_dir)) - .collect::>>()?, - None => Vec::new(), - }; - build_machine_spec(MachineSpecInputs { - name: &name, - image: self.image.as_deref(), - net: self.net, - allow_host: &self.allow_host, - peer: &self.peer, - routes: manifest_source - .as_ref() - .map(|source| source.routes.as_slice()) - .unwrap_or_default(), - gpu: self.gpu, - gpu_device: self.gpu_device, - ai: None, - cpus: self.cpus, - cpu_limit: self.cpu_limit, - timeout: self.timeout, - grants_file: self.grants_file.as_deref(), - memory: self.memory.as_deref(), - mem_initial: self.mem_initial.as_deref(), - profile: self.profile, - workflow, - volumes: &volumes, - init: &init, - policy: &self.policy, - project: manifest_source.as_ref().map(|source| &source.project), - }) - } -} - -fn run_profile_name(profile: RunProfile) -> &'static str { - profile.as_str() -} - use mvm_contract::hash::sha256_hex; -fn profile_allows_dev_init(profile: RunProfile) -> bool { - profile.grants().dev_guest -} - -/// `dev.init` runs commands in the guest before the workload, so it needs the -/// dev guest profile. -/// -/// Takes the persisted name because that is what a stored `MachineSpec` -/// carries. An unrecognised name refuses: a spec whose profile nobody knows -/// should stop the boot rather than be treated as whichever preset a string -/// comparison happened to miss. -fn enforce_dev_init_profile(profile: &str, init: &[String]) -> Result<()> { - if init.is_empty() { - return Ok(()); - } - let parsed = RunProfile::from_name(profile) - .ok_or_else(|| anyhow!("machine spec carries an unrecognised profile {profile:?}"))?; - if !profile_allows_dev_init(parsed) { - bail!( - "machine dev.init requires a dev-capable profile; use --profile dev or --profile permissive" - ); - } - Ok(()) -} - pub(super) fn validate_machine_name(name: &str) -> Result<()> { naming::validate_id(name, "machine name") } diff --git a/crates/mvm-cli/src/commands/machine/receipt.rs b/crates/mvm-cli/src/commands/machine/receipt.rs index a2144d32b2..2a26aa16df 100644 --- a/crates/mvm-cli/src/commands/machine/receipt.rs +++ b/crates/mvm-cli/src/commands/machine/receipt.rs @@ -13,6 +13,8 @@ pub(super) struct MachineStartReceiptInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) deployment: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) local_pack_archive_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) resolved_digest: Option, pub(super) cpus: u32, pub(super) memory: String, @@ -184,6 +186,9 @@ pub(super) fn machine_start_receipt_input( shared::launch_uses_oci_image(spec.image.as_deref(), spec.manifest.as_deref())?, &network_policy, )?; + if let Some(source) = &spec.local_pack { + source.verify_recipe()?; + } let _ = validate_machine_memory(&spec.memory, spec.mem_initial.as_deref())?; let volumes = machine_start_volume_policy(spec)?; Ok(MachineStartReceiptInput { @@ -191,6 +196,10 @@ pub(super) fn machine_start_receipt_input( image: spec.image.clone(), manifest: spec.manifest.clone(), deployment: spec.deployment.clone(), + local_pack_archive_sha256: spec + .local_pack + .as_ref() + .map(|source| source.archive_sha256.as_str().to_owned()), resolved_digest: spec.resolved_digest.clone(), cpus: spec.cpus, memory: spec.memory.clone(), @@ -242,12 +251,17 @@ pub(super) fn machine_start_preflight_summary( will_execute: false, machine: MachineStartPreflightMachine { name: spec.name.clone(), - image_reference_sha256: sha256_hex( - spec.image - .as_deref() - .or(spec.manifest.as_deref()) - .unwrap_or("") - .as_bytes(), + image_reference_sha256: spec.local_pack.as_ref().map_or_else( + || { + sha256_hex( + spec.image + .as_deref() + .or(spec.manifest.as_deref()) + .unwrap_or("") + .as_bytes(), + ) + }, + |source| source.recipe_sha256.as_str().to_owned(), ), resolved_digest: spec.resolved_digest.clone(), }, @@ -270,8 +284,13 @@ pub(super) fn machine_start_preflight_summary( pub(super) fn print_machine_start_preflight_human(summary: &MachineStartPreflightSummary) { println!("mvmctl machine start dry-run: no VM will be booted"); println!("machine: {}", summary.machine.name); + let source = if summary.invocation.local_pack_archive_sha256.is_some() { + "local pack recipe" + } else { + "OCI reference" + }; println!( - "image: OCI reference sha256={}{}", + "image: {source} sha256={}{}", summary.machine.image_reference_sha256, summary .machine diff --git a/crates/mvm-cli/src/commands/machine/runtime.rs b/crates/mvm-cli/src/commands/machine/runtime.rs index d37feb43df..bff48e328e 100644 --- a/crates/mvm-cli/src/commands/machine/runtime.rs +++ b/crates/mvm-cli/src/commands/machine/runtime.rs @@ -506,6 +506,10 @@ pub(crate) fn launch_carries_restricted_grant(args: &MachineRunArgs) -> bool { ) } +pub(super) fn entrypoint_dev_profile(args: &MachineRunArgs) -> bool { + matches!(args.run.profile, crate::commands::vm::exec::RunProfile::Dev) +} + /// Resolve a machine's `build_mode` (`"dev"` / `"prod"`) for the boot-time /// SDK envelope. Delegates to the shared inventory service's fail-closed /// posture resolution so this envelope, `machine ps --json`, and every other @@ -519,6 +523,7 @@ fn run_entrypoint_action( args: MachineRunArgs, resolved_flake_slot: Option, review_source: ReviewSource, + transient: Option<&mvm_client::launch::transient_composition::TransientBoot<'_>>, ) -> Result<()> { crate::commands::vm::exec::application_source::require_entrypoint_request(&args.run)?; if !args.run.outputs.is_empty() { @@ -539,7 +544,9 @@ fn run_entrypoint_action( default argv action — drop --entrypoint" ); } - let source = if args.attach { + let source = if let Some(boot) = transient { + boot.sha256().to_owned() + } else if args.attach { resolve_machine_run_name(&args)? } else if let Some(slot) = resolved_flake_slot { slot @@ -583,7 +590,7 @@ fn run_entrypoint_action( machine_name, reset: args.reset, keep_alive: args.persistent(), - keep_alive_dev: false, + dev_profile: entrypoint_dev_profile(&args), session: None, r#fn: None, attach: args.attach, @@ -593,6 +600,7 @@ fn run_entrypoint_action( network_policy, hypervisor: args.run.hypervisor.clone(), portable_boot_assets: None, + transient, }; if let Some(application) = args.run.resolved_application { return invoke::run_application_entrypoint(call, application); @@ -689,9 +697,27 @@ fn select_application_action(args: &mut MachineRunArgs) -> Result<()> { } pub(in crate::commands) fn run_dispatch( + cli: &Cli, + args: MachineRunArgs, + cfg: &MvmConfig, +) -> Result<()> { + run_dispatch_inner(cli, args, cfg, None) +} + +pub(in crate::commands) fn run_dispatch_composed( + cli: &Cli, + args: MachineRunArgs, + cfg: &MvmConfig, + transient: &mvm_client::launch::transient_composition::TransientBoot<'_>, +) -> Result<()> { + run_dispatch_inner(cli, args, cfg, Some(transient)) +} + +fn run_dispatch_inner( cli: &Cli, mut args: MachineRunArgs, cfg: &MvmConfig, + transient: Option<&mvm_client::launch::transient_composition::TransientBoot<'_>>, ) -> Result<()> { let startup_started = std::time::Instant::now(); // Settle the cold-build policy before any launch phase runs: `machine @@ -739,11 +765,21 @@ pub(in crate::commands) fn run_dispatch( // flag or as the positional source) becomes the installed bundle's sha256 // here, so nothing downstream parses the archive as an `mvm.toml` and the // boot is a plain `--manifest `. - crate::commands::bundle::settle_manifest_archive( - &mut args.run.manifest, - args.run.dry_run, - args.run.prod, - )?; + if let Some(boot) = transient { + boot.reverify()?; + // The private bundle is not a registry slot; admission receives its + // exact archive from the held boot instead of a durable manifest name. + args.run.manifest = None; + if let Some(environment) = boot.manifest().environment() { + crate::commands::vm::exec::environment_defaults::apply(&mut args.run, environment); + } + } else { + crate::commands::bundle::settle_manifest_archive( + &mut args.run.manifest, + args.run.dry_run, + args.run.prod, + )?; + } // Before the flake is built into a slot below: the project's `[policy]` // table is read from the flake directory the run names. crate::commands::vm::run_policy::apply_run_policy(&mut args.run)?; @@ -786,7 +822,7 @@ pub(in crate::commands) fn run_dispatch( ceiling through entrypoint admission; use machine run with an argv" ); } - return run_entrypoint_action(args, resolved_flake_slot, review_source); + return run_entrypoint_action(args, resolved_flake_slot, review_source, transient); } // A flake was built into a manifest slot above, and that image carries its diff --git a/crates/mvm-cli/src/commands/machine/tests.rs b/crates/mvm-cli/src/commands/machine/tests.rs index 977e4966c1..1579ec8bb1 100644 --- a/crates/mvm-cli/src/commands/machine/tests.rs +++ b/crates/mvm-cli/src/commands/machine/tests.rs @@ -740,6 +740,31 @@ fn run_defaults_match_the_lower_level_runner() { assert!(!args.interactive); } +#[test] +fn entrypoint_admission_uses_the_requested_run_profile() { + let dev = parse_run(&[ + "run", + "--entrypoint", + "--manifest", + "slot", + "--profile", + "dev", + ]) + .expect("parse dev entrypoint"); + assert!(super::runtime::entrypoint_dev_profile(&dev)); + + let standard = parse_run(&[ + "run", + "--entrypoint", + "--manifest", + "slot", + "--profile", + "standard", + ]) + .expect("parse standard entrypoint"); + assert!(!super::runtime::entrypoint_dev_profile(&standard)); +} + #[test] fn run_accepts_passthrough_flags() { let args = parse_run(&[ @@ -1178,6 +1203,7 @@ fn spec_fixture(name: &str) -> MachineSpec { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), @@ -1688,6 +1714,8 @@ fn create_args_from_manifest(manifest: &Path, profile: Option) -> Ma name: Some("web".to_string()), manifest: Some(manifest.display().to_string()), image: None, + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2177,6 +2205,110 @@ fn create_parses_persistent_spec_flags() { } } +#[test] +fn a_local_pack_create_records_only_source_data_and_conflicts_with_image_sources() { + let _state = IsolatedMachineState::new(); + let directory = tempfile::tempdir().expect("pack source"); + let recipe = directory.path().join("pack.toml"); + let archive = directory.path().join("composed.mvmpkg"); + std::fs::write(&recipe, b"source recipe").expect("recipe"); + std::fs::write(&archive, b"verified archive fixture").expect("archive"); + let action = parse(&[ + "create", + "packed", + "--pack", + recipe.to_str().expect("recipe path"), + ]) + .expect("pack create parses"); + let MachineAction::Create(args) = action else { + panic!("pack create must route to the named machine lifecycle"); + }; + let spec = args + .into_spec_with(|_, base| { + assert!( + base.is_none(), + "default pack selects the signed default base" + ); + Ok(archive.clone()) + }) + .expect("pack source is prepared"); + assert!(spec.image.is_none() && spec.manifest.is_none()); + assert!(spec.registry_pack_image.is_none()); + assert_eq!(spec.profile, "dev"); + let source = spec.local_pack.expect("local pack source"); + assert_eq!( + source.archive_sha256.as_str(), + mvm_core::packs::Sha256Hex::from_bytes(b"verified archive fixture").as_str() + ); + source.verify_recipe().expect("recipe pinned at create"); + assert!( + parse(&[ + "create", + "packed", + "--pack", + "pack.toml", + "--image", + "alpine" + ]) + .is_err() + ); + assert!(parse(&["create", "packed", "--pack", "pack.toml", "--manifest", "."]).is_err()); +} + +#[cfg(feature = "pr-test-guest-runtime")] +#[test] +fn a_named_pack_records_the_local_base_and_pr_runtime_as_data_for_fresh_start() { + let _state = IsolatedMachineState::new(); + let directory = tempfile::tempdir().expect("pack inputs"); + let recipe = directory.path().join("pack.toml"); + let archive = directory.path().join("composed.mvmpkg"); + let manifest = directory.path().join("image-set.json"); + let kernel = directory.path().join("vmlinux"); + let tar = directory.path().join("base.tar.gz"); + let runtime = directory.path().join("signed-runtime"); + std::fs::create_dir(&runtime).expect("runtime directory"); + for file in [&recipe, &archive, &manifest, &kernel, &tar] { + std::fs::write(file, b"fixture bytes").expect("test input"); + } + let args = [ + "create", + "packed", + "--pack", + recipe.to_str().expect("recipe"), + "--pack-base-manifest", + manifest.to_str().expect("manifest"), + "--pack-base-kernel", + kernel.to_str().expect("kernel"), + "--pack-base-tar", + tar.to_str().expect("tar"), + "--pack-pr-test-runtime-dir", + runtime.to_str().expect("runtime"), + "--pack-pr-test-pull-request", + "5326", + ]; + let MachineAction::Create(create) = parse(&args).expect("selected base parses") else { + panic!("named pack create action expected"); + }; + let spec = create + .into_spec_with(|_, selected| { + let selected = selected.expect("base reaches the preparer"); + assert_eq!(selected.manifest, std::fs::canonicalize(&manifest).unwrap()); + assert_eq!( + selected.pr_runtime.as_ref().map(|proof| proof.pull_request), + Some(5326) + ); + Ok(archive.clone()) + }) + .expect("prepared local source"); + let source = spec.local_pack.expect("source recorded"); + assert_eq!( + source.base.as_ref().expect("base retained").kernel, + std::fs::canonicalize(&kernel).unwrap() + ); + source.verify_recipe().expect("original source"); + assert!(parse(&args[..5]).is_err(), "partial base must not parse"); +} + #[test] fn readme_persistent_machine_workflow_parses() { let create = parse(&[ @@ -2541,6 +2673,7 @@ fn mark_machine_started_sets_digest_and_timestamp() { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2575,6 +2708,8 @@ fn create_persists_machine_spec_under_data_dir() { name: Some("web".to_string()), manifest: None, image: Some("alpine:latest".to_string()), + pack: None, + pack_base: MachinePackBaseArgs::default(), net: true, allow_host: vec!["api.example.com".to_string()], peer: Vec::new(), @@ -2610,6 +2745,8 @@ fn create_auto_generates_a_name_when_omitted() { name: None, manifest: None, image: Some("alpine:latest".to_string()), + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2663,6 +2800,8 @@ volumes = ["./state.img:/data:1G:rw"] name: Some("web".to_string()), manifest: Some(dir.path().join("mvm.toml").display().to_string()), image: None, + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2710,6 +2849,8 @@ fn create_rejects_flake_backed_manifest_for_machine_specs() { name: Some("web".to_string()), manifest: Some(dir.path().join("mvm.toml").display().to_string()), image: None, + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2747,6 +2888,8 @@ fn create_defaults_to_dev_profile_when_manifest_declares_dev_init() { name: Some("web".to_string()), manifest: Some(dir.path().join("mvm.toml").display().to_string()), image: None, + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2771,6 +2914,8 @@ fn create_defaults_to_dev_profile_when_manifest_declares_dev_init() { name: Some("web".to_string()), manifest: Some(dir.path().join("mvm.toml").display().to_string()), image: None, + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2806,6 +2951,7 @@ fn machine_start_receipt_input_redacts_host_paths_and_surfaces_policy() { resolved_digest: Some("sha256:abc".to_string()), runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec!["api.example.com".to_string()], peer: Vec::new(), @@ -2850,6 +2996,33 @@ fn machine_start_receipt_input_redacts_host_paths_and_surfaces_policy() { assert!(json.contains("allow-list:api.example.com:443")); } +#[test] +fn named_pack_start_receipt_pins_archive_without_exposing_recipe_path() { + let _state = IsolatedMachineState::new(); + let directory = tempfile::tempdir().expect("local source"); + let recipe = directory.path().join("pack.toml"); + std::fs::write(&recipe, b"recipe").expect("recipe"); + let mut spec = spec_fixture("packed"); + spec.image = None; + spec.profile = "dev".into(); + spec.local_pack = Some( + mvm_runtime::machine::persist::LocalPackSource::from_prepared( + &recipe, + mvm_core::packs::Sha256Hex::from_bytes(b"archive"), + ) + .expect("source pin"), + ); + let receipt = machine_start_receipt_input(&spec, "firecracker").expect("receipt input"); + let encoded = serde_json::to_string(&receipt).expect("receipt JSON"); + assert!(encoded.contains("local_pack_archive_sha256")); + assert!(!encoded.contains(recipe.to_str().expect("recipe path"))); + std::fs::write(&recipe, b"changed").expect("change recipe"); + assert!( + machine_start_receipt_input(&spec, "firecracker").is_err(), + "changed recipe must refuse before reporting a start" + ); +} + #[test] fn machine_start_receipt_is_signed_and_verifiable() { let _state = IsolatedMachineState::new(); @@ -2860,6 +3033,7 @@ fn machine_start_receipt_is_signed_and_verifiable() { image: Some("ghcr.io/acme/web:latest".to_string()), manifest: None, deployment: None, + local_pack_archive_sha256: None, resolved_digest: Some("sha256:abc".to_string()), cpus: 2, memory: "512M".to_string(), @@ -2904,6 +3078,7 @@ fn machine_start_preflight_reports_uniform_l4_enforcement_for_oci_allow_host() { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec!["api.example.com".to_string()], peer: Vec::new(), @@ -2945,6 +3120,8 @@ fn create_rejects_unsafe_machine_name() { name: Some("../web".to_string()), manifest: None, image: Some("alpine:latest".to_string()), + pack: None, + pack_base: MachinePackBaseArgs::default(), net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -2977,6 +3154,7 @@ fn create_refuses_overwrite_without_force() { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -3017,6 +3195,7 @@ fn remove_machine_spec_requires_confirmation_and_deletes_dir() { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -3059,6 +3238,7 @@ fn seed_machine_spec(name: &str) { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -3730,6 +3910,7 @@ fn reconfigure_spec_fixture() -> MachineSpec { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-cli/src/commands/pack/build.rs b/crates/mvm-cli/src/commands/pack/build.rs index 577013e63f..5136b190a1 100644 --- a/crates/mvm-cli/src/commands/pack/build.rs +++ b/crates/mvm-cli/src/commands/pack/build.rs @@ -313,7 +313,7 @@ fn pack_ref(lock: &PackLock) -> String { format!("{}@{}", lock.identity.name, lock.identity.version) } -fn health_call(slot: &str, spec: &PackSpec, args: &BuildArgs) -> EntrypointCall { +fn health_call(slot: &str, spec: &PackSpec, args: &BuildArgs) -> EntrypointCall<'static> { EntrypointCall { source: slot.to_string(), stdin: EntrypointStdin::OneShot(Vec::new()), @@ -328,7 +328,7 @@ fn health_call(slot: &str, spec: &PackSpec, args: &BuildArgs) -> EntrypointCall reset: false, keep_alive: false, machine_name: None, - keep_alive_dev: false, + dev_profile: false, session: None, r#fn: None, attach: false, @@ -338,6 +338,7 @@ fn health_call(slot: &str, spec: &PackSpec, args: &BuildArgs) -> EntrypointCall network_policy: mvm_core::network_policy::NetworkPolicy::deny_all(), hypervisor: args.hypervisor.clone(), portable_boot_assets: None, + transient: None, } } diff --git a/crates/mvm-cli/src/commands/pack/inspect.rs b/crates/mvm-cli/src/commands/pack/inspect.rs index 067c063b1d..bf3b2ebde5 100644 --- a/crates/mvm-cli/src/commands/pack/inspect.rs +++ b/crates/mvm-cli/src/commands/pack/inspect.rs @@ -61,6 +61,14 @@ pub(super) fn run(reference_arg: &str, json: bool, verify_only: bool) -> Result< VerifiedRegistryManifest::Application(application) => { return inspect_application(&application, json, verify_only); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!( + "portable application component inspection requires verified archive custody" + ); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("runtime/tool component inspection requires verified archive custody"); + } VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("runtime layer inspection requires verified archive custody"); } diff --git a/crates/mvm-cli/src/commands/tests.rs b/crates/mvm-cli/src/commands/tests.rs index 07e54d867e..f34d0b2add 100644 --- a/crates/mvm-cli/src/commands/tests.rs +++ b/crates/mvm-cli/src/commands/tests.rs @@ -4844,6 +4844,32 @@ fn composed_run_parses_one_command_and_refuses_other_boot_sources() { Some(std::path::Path::new("./pack.toml")) ); exec::validate_composition_request(&machine).unwrap(); + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--profile", + "standard", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { machine, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert!(exec::validate_composition_request(&machine).is_err()); + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--grants-file", + "./grants.toml", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { machine, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert!(exec::validate_composition_request(&machine).is_err()); let parsed = Cli::try_parse_from([ "mvmctl", "run", @@ -4870,6 +4896,150 @@ fn composed_run_parses_one_command_and_refuses_other_boot_sources() { ); } +#[test] +fn composed_run_requires_an_explicit_complete_local_base_selection() { + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!(sdk.compose_base, Some(exec::ComposeBase::Local)); + assert_eq!( + sdk.compose_base_manifest.as_deref(), + Some(std::path::Path::new("./local-image-set.json")) + ); + for args in [ + vec![ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base-manifest", + "./local-image-set.json", + ], + vec![ + "mvmctl", + "run", + "--compose-base", + "local", + "--compose-base-kernel", + "./vmlinux", + ], + ] { + assert!(Cli::try_parse_from(args).is_err()); + } + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "signed-default", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!(sdk.compose_base, Some(exec::ComposeBase::SignedDefault)); + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + "--prod", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { machine, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert!( + exec::validate_composition_request(&machine).is_err(), + "a local composition must never inherit a production request" + ); +} + +#[cfg(feature = "pr-test-guest-runtime")] +#[test] +fn pr_test_runtime_flags_are_explicit_and_require_the_complete_pair() { + let local = [ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + ]; + for suffix in [ + vec!["--compose-pr-test-runtime-dir", "./quartet"], + vec!["--compose-pr-test-pull-request", "42"], + ] { + assert!(Cli::try_parse_from(local.iter().copied().chain(suffix)).is_err()); + } + let parsed = Cli::try_parse_from(local.iter().copied().chain([ + "--profile", + "dev", + "--compose-pr-test-runtime-dir", + "./quartet", + "--compose-pr-test-pull-request", + "42", + ])) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, machine }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!( + sdk.compose_pr_test_runtime_dir.as_deref(), + Some(std::path::Path::new("./quartet")) + ); + assert_eq!(sdk.compose_pr_test_pull_request, Some(42)); + assert!(!machine.run.prod); +} + +#[cfg(not(feature = "pr-test-guest-runtime"))] +#[test] +fn ordinary_binary_does_not_parse_pr_test_runtime_flags() { + assert!( + Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-pr-test-runtime-dir", + "./quartet", + "--compose-pr-test-pull-request", + "42", + ]) + .is_err() + ); +} + #[test] fn run_transient_launch_plan_conflicts_with_argv() { let cli = Cli::try_parse_from([ diff --git a/crates/mvm-cli/src/commands/vm/exec.rs b/crates/mvm-cli/src/commands/vm/exec.rs index 1c21d7126a..cbe4990c72 100644 --- a/crates/mvm-cli/src/commands/vm/exec.rs +++ b/crates/mvm-cli/src/commands/vm/exec.rs @@ -24,6 +24,40 @@ use super::host_signer::{host_signer_id, load_or_init}; pub(in crate::commands) mod application_source; mod composition; +mod composition_request; +pub(in crate::commands) use composition_request::SdkTransportArgs; +pub(in crate::commands) fn prepare_local_pack_data( + path: &Path, + selected: Option<&mvm_client::machine_spec::LocalPackBaseSource>, +) -> Result { + let base = if let Some(selected) = selected { + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = selected + .pr_runtime + .as_ref() + .map(|proof| composition::PrTestInput { + directory: proof.directory.as_path(), + pull_request: proof.pull_request, + }); + #[cfg(not(feature = "pr-test-guest-runtime"))] + anyhow::ensure!( + selected.pr_runtime.is_none(), + "PR test runtime requires a fixture-enabled CLI" + ); + composition::Base::Local { + manifest: &selected.manifest, + kernel: &selected.kernel, + tar: &selected.tar, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test, + } + } else { + composition::Base::SignedDefault + }; + composition::prepare_archive(path, base) +} +#[cfg(test)] +pub(in crate::commands) use composition_request::validate_composition_request; pub(in crate::commands) mod detect; mod image_source; pub(in crate::commands) mod positional_source; @@ -430,43 +464,6 @@ const PLAN_EXCLUDES: [&str; 12] = [ "secret", ]; -/// The SDK transport surface, carried by `mvmctl run` alone. -/// -/// These are kept out of [`RunArgs`] deliberately. `RunArgs` is flattened into -/// both `run` and `machine run` so a shared flag is declared exactly once; -/// `--mode`/`--dev`/`--ack-divergence` are not shared — `machine run` is the -/// beginner contract and has no business growing an SDK transport — so putting -/// them here is what lets the rest be flattened. -#[derive(ClapArgs, Debug, Clone, Default)] -pub(in crate::commands) struct SdkTransportArgs { - /// Assemble a signed, digest-pinned pack recipe and run its entrypoint. - #[arg(long = "compose", value_name = "PACK.TOML", conflicts_with_all = ["mode", "dev"])] - pub compose: Option, - /// SDK transport mode for `mvmctl run`. - /// - /// - `--mode plan`: synthesize an ExecutionPlan per Sandbox call - /// and route through `mvm_hostd::supervisor::admit_for_run`; no - /// microVM ever boots. - /// - `--mode live`: spawn the user's script with `MVM_SDK_MODE=live` - /// so the SDK shells each `Sandbox` operation to existing - /// `mvmctl` verbs against a real microVM. - /// - `--mode record` redirects users to `mvmctl dev compile` (where - /// record is the default mode). - /// - /// When unset, the verb behaves as a transient-sandbox runner - /// over the trailing argv. - #[arg(long = "mode", value_enum)] - pub mode: Option, - /// Friendly alias for `--mode live`. - #[arg(long = "dev", conflicts_with_all = ["prod", "mode"])] - pub dev: bool, - /// Acknowledge a divergence class on the plan-mode admission - /// path (repeatable). Unacknowledged divergence refuses - /// admission: what you previewed is not what would ship. - #[arg(long = "ack-divergence", value_name = "KIND")] - pub ack_divergence: Vec, -} - /// `mvmctl run` — `machine run`, plus the SDK transport only `run` carries. #[derive(ClapArgs, Debug, Clone, Default)] pub(in crate::commands) struct RunVerbArgs { @@ -476,6 +473,12 @@ pub(in crate::commands) struct RunVerbArgs { pub sdk: SdkTransportArgs, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(in crate::commands) enum ComposeBase { + SignedDefault, + Local, +} + /// SDK transport modes for `mvmctl run`. Mirrors the `Mode` enum on /// `mvmctl dev compile` but specialises the rejection messages to point /// users at the right verb when they pick the wrong default. @@ -586,20 +589,16 @@ pub(in crate::commands) fn run_verb( mut args: RunVerbArgs, cfg: &MvmConfig, ) -> Result<()> { - let composition = if let Some(path) = args.sdk.compose.take() { - validate_composition_request(&args.machine)?; - let built = composition::prepare(&path)?; - args.machine.run.manifest = Some(built.archive().to_string_lossy().into_owned()); - args.machine.entrypoint = true; - Some(built) - } else { - None - }; + let mut composition = composition_request::prepare_composition(&mut args)?; let run = &mut args.machine.run; // Checked before the script is moved into argv below: a script path never // looks like a flag, so it would hide a misplaced one behind it. detect::refuse_run_flag_after_double_dash(&run.argv)?; if let Some(mode) = resolve_run_mode(&args.sdk, run)? { + anyhow::ensure!( + composition.is_none(), + "--compose cannot select an SDK transport mode" + ); // Under an SDK mode the positional names the script to run, not a boot // source: `mvmctl run --mode plan ./script.py`. if let Some(script) = run.source.take() { @@ -608,36 +607,27 @@ pub(in crate::commands) fn run_verb( return super::run_plan::dispatch_sdk_mode(mode, run, &args.sdk); } args.machine.default_image_when_sourceless = true; - let result = crate::commands::machine::run_dispatch(cli, args.machine, cfg); + let result = if let Some(prepared) = composition.as_mut() { + let boot = prepared.consume()?; + let result = crate::commands::machine::run_dispatch_composed(cli, args.machine, cfg, &boot); + let admitted = boot.was_admitted(); + if result.is_err() + && admitted + && let Some(path) = prepared.retain_on_error() + { + eprintln!( + "[mvm] retained invocation-private bundle evidence at {} after a failed admitted run", + path.display() + ); + } + result + } else { + crate::commands::machine::run_dispatch(cli, args.machine, cfg) + }; drop(composition); result } -pub(in crate::commands) fn validate_composition_request( - machine: &crate::commands::machine::MachineRunArgs, -) -> Result<()> { - let run = &machine.run; - anyhow::ensure!( - run.source.is_none() - && run.application.is_none() - && run.image.is_none() - && run.manifest.is_none() - && run.flake.is_none() - && run.deployment.is_none() - && run.runtime.is_none() - && !run.runtime_pack - && !run.prod - && !run.dry_run - && run.launch_plan.is_none() - && run.argv.is_empty() - && !machine.interactive() - && !machine.persistent() - && !machine.attach, - "--compose requires an unambiguous transient development run without another boot source or command" - ); - Ok(()) -} - /// Run a transient workload through the admitted path from the instant launch /// resolution began, optionally overriding lookup with a content-pinned source. pub(in crate::commands) fn run_secure_started( @@ -786,6 +776,7 @@ pub(in crate::commands) fn run_secure_started( volumes, bundle_archive, runtime_config, + transient: _, } = inputs; denials_for_admit.arm(vm_name); let ledger = mvm_client::admission::InMemoryNonceLedger::default(); @@ -2127,10 +2118,10 @@ mod tests { /// shared `RunArgs` separately, so each case names which half it exercises. fn sdk(mode: Option, dev: bool) -> SdkTransportArgs { SdkTransportArgs { - compose: None, mode, dev, ack_divergence: Vec::new(), + ..Default::default() } } diff --git a/crates/mvm-cli/src/commands/vm/exec/application_source.rs b/crates/mvm-cli/src/commands/vm/exec/application_source.rs index 7354ad4fc1..24833e1d16 100644 --- a/crates/mvm-cli/src/commands/vm/exec/application_source.rs +++ b/crates/mvm-cli/src/commands/vm/exec/application_source.rs @@ -37,6 +37,11 @@ pub(in crate::commands) fn require_entrypoint_request(args: &RunArgs) -> Result< if args.resolved_application.is_none() { return Ok(()); } + require_held_entrypoint_request(args) +} + +/// Refuse flags the verified bundle entrypoint cannot carry into admission. +pub(in crate::commands) fn require_held_entrypoint_request(args: &RunArgs) -> Result<()> { anyhow::ensure!( args.grants_file.is_none() && args.cpu_limit.is_none() @@ -51,7 +56,7 @@ pub(in crate::commands) fn require_entrypoint_request(args: &RunArgs) -> Result< && args.mounts.is_empty() && args.env.is_empty() && args.allow_env.is_empty(), - "application --entrypoint cannot yet preserve --grants-file, --cpu-limit, \ + "bundle --entrypoint cannot yet preserve --grants-file, --cpu-limit, \ --dry-run, --launch-plan, --peer, --host-service, --asset, --receipt, \ GPU options, --mount, --env or --allow-env" ); diff --git a/crates/mvm-cli/src/commands/vm/exec/composition.rs b/crates/mvm-cli/src/commands/vm/exec/composition.rs index d507be3e63..b2e5f624a1 100644 --- a/crates/mvm-cli/src/commands/vm/exec/composition.rs +++ b/crates/mvm-cli/src/commands/vm/exec/composition.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, ensure}; use mvm_build::published_image_set::PublishedImageSet; -use mvm_build::runtime_layer::compose_authored_candidate; +use mvm_build::runtime_layer::{ + ResolvedComposableLock, compose_authored_candidate, compose_local_authored_candidate, +}; use mvm_client::bundle::{BundleExportInputs, BundleSigner, HostBundleSigner, PostureInputs}; use mvm_client::portable_boot_assets::PreparedPortableBootAssets; use mvm_client::registry_application::discovery::resolve_runtime_layer_pin; @@ -31,15 +33,58 @@ use crate::pack_registry::{self, PackIndexEntry, PackRegistryConfig}; /// Retains the unpublished local archive through verification and launch. pub(super) struct PreparedComposition { archive: PathBuf, + private: mvm_client::launch::transient_composition::TransientComposition, } impl PreparedComposition { pub(super) fn archive(&self) -> &Path { &self.archive } + + pub(super) fn consume( + &mut self, + ) -> Result> { + self.private.consume() + } + + pub(super) fn retain_on_error(&mut self) -> Option { + self.private.retain_on_error() + } +} + +pub(super) enum Base<'a> { + SignedDefault, + #[cfg(feature = "pr-test-synthetic-base")] + Synthetic { + fixture: mvm_build::pr_test_synthetic_base::PrTestSyntheticBase, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test: PrTestInput<'a>, + }, + Local { + manifest: &'a Path, + kernel: &'a Path, + tar: &'a Path, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test: Option>, + }, +} + +#[cfg(feature = "pr-test-guest-runtime")] +#[derive(Clone, Copy)] +pub(super) struct PrTestInput<'a> { + pub directory: &'a Path, + pub pull_request: u64, +} + +pub(super) fn prepare(path: &Path, base: Base<'_>) -> Result { + let archive = prepare_archive(path, base)?; + let private = mvm_client::launch::transient_composition::TransientComposition::stage(&archive)?; + Ok(PreparedComposition { archive, private }) } -pub(super) fn prepare(path: &Path) -> Result { +/// Verify and cache composition data without granting a boot capability. +#[tracing::instrument(level = "trace", skip_all)] +pub(super) fn prepare_archive(path: &Path, base: Base<'_>) -> Result { let metadata = std::fs::symlink_metadata(path)?; ensure!( metadata.file_type().is_file() && metadata.len() <= 64 * 1024, @@ -79,54 +124,298 @@ pub(super) fn prepare(path: &Path) -> Result { }) .collect::>()? }; - let images = PublishedImageSet::acquire_workload()?; let profile = WorkloadImageProfile::DefaultTenant; - let tree = compose_authored_candidate(&spec, &images, profile, &ordered)?; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = match &base { + Base::SignedDefault => None, + #[cfg(feature = "pr-test-synthetic-base")] + Base::Synthetic { pr_test, .. } => Some(*pr_test), + Base::Local { pr_test, .. } => *pr_test, + }; + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic = match &base { + Base::Synthetic { fixture, .. } => Some(fixture), + Base::SignedDefault | Base::Local { .. } => None, + }; + let local = match &base { + Base::SignedDefault => None, + #[cfg(feature = "pr-test-synthetic-base")] + Base::Synthetic { fixture, .. } => { + let verified = fixture.verify(arch)?; + Some(( + verified.manifest_path, + verified.manifest, + verified.kernel, + verified.tar, + )) + } + Base::Local { + manifest, + kernel, + tar, + .. + } => { + let metadata = std::fs::symlink_metadata(manifest)?; + ensure!( + metadata.file_type().is_file() + && metadata.len() <= mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES, + "local base manifest must be a bounded regular file" + ); + for input in [kernel, tar] { + ensure!( + std::fs::symlink_metadata(input)?.file_type().is_file(), + "local base kernel and tar must be regular, not links" + ); + } + Some(( + manifest.to_path_buf(), + std::fs::read(manifest)?, + std::fs::canonicalize(kernel)?, + std::fs::canonicalize(tar)?, + )) + } + }; + let images = if local.is_none() { + Some(PublishedImageSet::acquire_workload()?) + } else { + None + }; + let tree = if let Some((_, manifest, kernel, tar)) = &local { + compose_local_authored_candidate(&spec, manifest, kernel, tar, profile, &ordered)? + } else { + compose_authored_candidate( + &spec, + images.as_ref().context("signed base was not acquired")?, + profile, + &ordered, + )? + }; let prepared = prepare_composable_application(&spec, directory)?; prepared.reverify()?; let archive_sha = Sha256Hex::new(prepared.archive_sha256())?; let source_sha = Sha256Hex::new(prepared.source_sha256())?; - let application = tree.with_application_archive(prepared.archive_bytes(), &archive_sha)?; + let application = tree + .with_application_archive(prepared.archive_bytes(), &archive_sha)? + .with_authored_entrypoint(&spec)?; let lock = application.development_lock(&spec, &source_sha, env!("MVM_SOURCE_COMMIT"))?; let lock_bytes = serde_json::to_vec(&lock)?; let lock_sha = Sha256Hex::from_bytes(&lock_bytes); + let filesystem_identity = lock.filesystem_identity()?; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test_proof = if let Some(input) = pr_test { + let name = mvm_build::guest_bins::guest_bins_archive_name(env!("CARGO_PKG_VERSION")); + let archive = input.directory.join(&name); + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: mvm_core::protocol::runtime_release::RuntimeReleaseProof { + archive: archive.clone(), + checksum: input.directory.join(format!("{name}.sha256")), + signature_bundle: input.directory.join(format!("{name}.bundle")), + archive_sha256: mvm_core::crypto::image_verify::sha256_file(&archive)?, + version: env!("CARGO_PKG_VERSION").into(), + arch, + }, + pull_request: input.pull_request, + }; + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + &proof, + env!("CARGO_PKG_VERSION"), + arch, + )?; + Some(proof) + } else { + None + }; + // Local source custody is path-bound, even when two paths have identical bytes. + let cache_sha = if let Some((_, _, _, tar)) = &local { + #[cfg(feature = "pr-test-guest-runtime")] + let ordinary = if let Some(proof) = &pr_test_proof { + Sha256Hex::from_bytes( + &[ + lock_bytes.as_slice(), + tar.as_os_str().as_encoded_bytes(), + &proof.pull_request.to_be_bytes(), + proof.input.archive_sha256.as_bytes(), + ] + .concat(), + ) + } else { + Sha256Hex::from_bytes( + &[lock_bytes.as_slice(), tar.as_os_str().as_encoded_bytes()].concat(), + ) + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let ordinary = Sha256Hex::from_bytes( + &[lock_bytes.as_slice(), tar.as_os_str().as_encoded_bytes()].concat(), + ); + #[cfg(feature = "pr-test-synthetic-base")] + { + if let Some(fixture) = synthetic { + Sha256Hex::from_bytes( + &[ + b"pr-test-synthetic-base".as_slice(), + ordinary.as_str().as_bytes(), + &fixture.pull_request().to_be_bytes(), + ] + .concat(), + ) + } else { + ordinary + } + } + #[cfg(not(feature = "pr-test-synthetic-base"))] + ordinary + } else { + lock_sha.clone() + }; let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()).join("compositions"); mvm_core::config::create_private_dir(&cache)?; - let target = cache.join(format!("{}.mvmpkg", lock_sha.as_str())); + let target = cache.join(format!("{}.mvmpkg", cache_sha.as_str())); let reverify_inputs = || -> Result<()> { prepared.reverify()?; + #[cfg(feature = "pr-test-synthetic-base")] + if let Some(fixture) = synthetic { + let current = fixture.verify(arch)?; + let (_, original, kernel, tar) = local + .as_ref() + .context("synthetic base selection was lost")?; + ensure!( + current.manifest == *original && current.kernel == *kernel && current.tar == *tar, + "PR synthetic base selection changed before use" + ); + } + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &pr_test_proof { + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + arch, + )?; + } for input in &inputs { mvm_core::registry_pack::runtime_layer::require_offline_runtime_policy(input)?; } - images.retained_publisher_proof()?; + if let Some((manifest_path, original, kernel, tar)) = &local { + let metadata = std::fs::symlink_metadata(manifest_path)?; + ensure!( + metadata.file_type().is_file() + && metadata.len() == original.len() as u64 + && std::fs::read(manifest_path)? == *original, + "local base manifest changed before signing" + ); + let declared: mvm_core::image_set::ImageSetManifest = serde_json::from_slice(original)?; + mvm_core::image_set::check_workload_protocol_compatibility( + &declared.compatibility, + &mvm_build::stage0_kernel::current_image_set_protocol_support(), + )?; + let (selected_kernel, selected_tar) = + mvm_core::plan::bundle::select_local_development_source(&declared, arch, profile)?; + for (file, artifact) in [(kernel, selected_kernel), (tar, selected_tar)] { + ensure!( + std::fs::symlink_metadata(file)?.file_type().is_file() + && std::fs::metadata(file)?.len() == artifact.size + && mvm_core::crypto::image_verify::sha256_file(file)? + == artifact.sha256.as_str(), + "selected local base input changed before signing" + ); + } + } else { + images + .as_ref() + .context("signed base was not acquired")? + .retained_publisher_proof()?; + } Ok(()) }; if std::fs::symlink_metadata(&target).is_ok() { reverify_inputs()?; - verify_cached_bundle(&target, &lock_bytes)?; + verify_cached_bundle( + &target, + &lock_bytes, + local.as_ref().map(|(_, _, _, tar)| tar.as_path()), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof + .as_ref() + .map(|proof| (proof.pull_request, proof.input.archive_sha256.as_str())), + #[cfg(not(feature = "pr-test-guest-runtime"))] + None, + )?; eprintln!( "[mvm] authenticated cached composition {}", - lock_sha.as_str() + cache_sha.as_str() ); - return Ok(PreparedComposition { archive: target }); + return Ok(target); } - let boot = PreparedPortableBootAssets::acquire(arch)?; - ensure!( - boot.image_set_manifest_sha256() == lock.base_manifest_sha256(), - "composition and signed CLI boot closure selected different Linux roots" - ); + #[cfg(feature = "pr-test-guest-runtime")] + let boot = if let Some(input) = pr_test { + PreparedPortableBootAssets::acquire_pr_test_local( + arch, + input.directory, + input.pull_request, + )? + } else if local.is_some() { + PreparedPortableBootAssets::acquire_local(arch)? + } else { + PreparedPortableBootAssets::acquire(arch)? + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let boot = if local.is_some() { + PreparedPortableBootAssets::acquire_local(arch)? + } else { + PreparedPortableBootAssets::acquire(arch)? + }; + if local.is_none() { + ensure!( + boot.image_set_manifest_sha256() == lock.base_manifest_sha256(), + "composition and signed CLI boot closure selected different Linux roots" + ); + } + reverify_inputs()?; + let prior = previous_filesystem_bundle(&cache, &filesystem_identity)?; + let reused = prior + .map(|(verified, archive)| verified.extract_rootfs_with_verity(archive)) + .transpose()?; let directory = tempfile::tempdir()?; mvm_core::config::create_private_dir(directory.path())?; - let image = directory.path().join("rootfs.ext4"); - let materialized = application.materialize_candidate(&image)?; - let roothash = materialized - .verity_root_hash - .as_deref() - .context("composed rootfs did not produce dm-verity")?; + let image = reused.as_ref().map_or_else( + || directory.path().join("rootfs.ext4"), + |root| root.rootfs.clone(), + ); + let (roothash, verity_path) = if let Some(root) = &reused { + (root.roothash.clone(), root.verity.clone()) + } else { + let materialized = application.materialize_candidate(&image)?; + ( + materialized + .verity_root_hash + .context("composed rootfs did not produce dm-verity")?, + directory.path().join("rootfs.verity"), + ) + }; let output_sha256 = mvm_core::crypto::image_verify::sha256_file(&image)?; - images - .workload_sidecar(&spec.identity.name, spec.entrypoint.clone()) - .write_to_dir(directory.path())?; + let mut sidecar = mvm_build::builder_vm::GuestSidecar { + name: spec.identity.name.clone(), + accessible: false, + sealed: true, + entrypoint_kind: "command".to_owned(), + entrypoint_argv: spec.entrypoint.clone(), + init_system: "cli-initramfs".to_owned(), + expected_boot_ms: 300, + agent_binary: String::new(), + rootless_entrypoint: true, + hypervisor: "firecracker".to_owned(), + overlay_aware: true, + runtime_lean: true, + image_tag: String::new(), + source: String::new(), + built_at: String::new(), + protocol_version: 0, + generator_rev: String::new(), + libc: Default::default(), + }; + if let Some(images) = &images { + sidecar = images.workload_sidecar(&spec.identity.name, spec.entrypoint.clone()); + } + sidecar.write_to_dir(directory.path())?; let (member, evidence) = retain_evidence(directory.path(), &lock_bytes, &ordered)?; let supplemental: Vec<(&str, &Path)> = evidence .iter() @@ -150,10 +439,11 @@ pub(super) fn prepare(path: &Path) -> Result { capabilities: Vec::new(), services: Vec::new(), }; - let verity = std::fs::read(directory.path().join("rootfs.verity"))?; + let verity = std::fs::read(&verity_path)?; let arch_label = arch.to_string(); - let kernel = boot - .kernel_path() + let kernel = local + .as_ref() + .map_or_else(|| boot.kernel_path(), |(_, _, kernel, _)| kernel.as_path()) .to_str() .context("kernel path is not UTF-8")?; let rootfs = image.to_str().context("rootfs path is not UTF-8")?; @@ -163,7 +453,7 @@ pub(super) fn prepare(path: &Path) -> Result { .context("initramfs path is not UTF-8")?; let mut export = BundleExportInputs::new(kernel, rootfs, &arch_label, &target) .initrd(initrd) - .verity(&verity, roothash) + .verity(&verity, &roothash) .profile("dev") .resources(spec.resources.cpu_cores.into(), spec.resources.memory_mb) .posture(PostureInputs::new(AgentProfile::Dev)) @@ -178,7 +468,35 @@ pub(super) fn prepare(path: &Path) -> Result { .local_build(); export.local_composition = Some(member); export.supplemental_artifacts = &supplemental; - let signed = boot.stage(&export, &HostBundleSigner::load()?)?; + reverify_inputs()?; + let signed = if let Some((_, manifest_bytes, _, tar)) = &local { + let source: mvm_core::image_set::ImageSetManifest = serde_json::from_slice(manifest_bytes)?; + let member = source + .members + .iter() + .find(|member| { + member.role == mvm_core::image_set::ImageSetRole::WorkloadRootfs(profile) + && member.target == mvm_core::image_set::MemberTarget::Arch(arch) + }) + .context("selected local base root is missing")?; + let tar_artifact = member + .artifacts + .iter() + .find(|artifact| artifact.format == mvm_core::image_set::ArtifactFormat::TarGz) + .context("selected local composition tar is missing")?; + let proof = mvm_core::plan::bundle::LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".to_owned(), + composition_tar_sha256: tar_artifact.sha256.as_str().to_owned(), + composition_tar_size: tar_artifact.size, + composition_tar_path: tar + .to_str() + .context("local tar path is not UTF-8")? + .to_owned(), + }; + boot.stage_local(&export, &HostBundleSigner::load()?, manifest_bytes, proof)? + } else { + boot.stage(&export, &HostBundleSigner::load()?)? + }; reverify_inputs()?; ensure!( mvm_core::crypto::image_verify::sha256_file(&image)? == output_sha256, @@ -190,15 +508,35 @@ pub(super) fn prepare(path: &Path) -> Result { } reverify_inputs()?; } - verify_cached_bundle(&target, &lock_bytes)?; - eprintln!( - "[mvm] assembled and cached composition {}", - lock_sha.as_str() - ); - Ok(PreparedComposition { archive: target }) + reverify_inputs()?; + record_filesystem_bundle(&cache, &filesystem_identity, &cache_sha)?; + verify_cached_bundle( + &target, + &lock_bytes, + local.as_ref().map(|(_, _, _, tar)| tar.as_path()), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof + .as_ref() + .map(|proof| (proof.pull_request, proof.input.archive_sha256.as_str())), + #[cfg(not(feature = "pr-test-guest-runtime"))] + None, + )?; + if reused.is_some() { + eprintln!( + "[mvm] reused verified filesystem {} for composition {}", + filesystem_identity.as_str(), + cache_sha.as_str() + ); + } else { + eprintln!( + "[mvm] assembled and cached composition {}", + cache_sha.as_str() + ); + } + Ok(target) } -fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { +fn verified_local_bundle(path: &Path) -> Result { ensure!( std::fs::symlink_metadata(path)?.file_type().is_file(), "composed cache entry must be a regular file" @@ -215,6 +553,92 @@ fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { bundle_origin(&verified.manifest, &id, trust.release()) == BundleOrigin::LocalBuild, "composed cache entry is not a host-signed local build" ); + Ok(verified) +} + +fn previous_filesystem_bundle( + cache: &Path, + identity: &Sha256Hex, +) -> Result> { + let index = cache.join(format!("rootfs-{}.ref", identity.as_str())); + let metadata = match std::fs::symlink_metadata(&index) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + ensure!( + metadata.file_type().is_file() && metadata.len() <= 128, + "filesystem cache index must be a bounded regular file" + ); + let entry = std::fs::read_to_string(&index)?; + let digest = entry + .strip_suffix(".mvmpkg\n") + .context("filesystem cache index has an invalid archive name")?; + let digest = Sha256Hex::new(digest.to_owned())?; + let archive = cache.join(format!("{}.mvmpkg", digest.as_str())); + let verified = verified_local_bundle(&archive)?; + let member = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalComposition(member) => Some(member), + _ => None, + }) + .context("filesystem cache source has no signed composition lock")?; + let artifact = verified + .manifest + .find_by_name(&member.lock_artifact) + .context("filesystem cache source has no composition lock")?; + ensure!( + artifact.role == mvm_core::plan::bundle::ArtifactRole::Other, + "filesystem cache source lock has the wrong artifact role" + ); + let original: ResolvedComposableLock = + serde_json::from_slice(&verified.read_small_verified_member( + std::fs::File::open(&archive)?, + &member.lock_artifact, + 1024 * 1024, + )?)?; + ensure!( + original.filesystem_identity()? == *identity, + "filesystem cache source differs from today's ordered filesystem inputs" + ); + Ok(Some((verified, std::fs::File::open(archive)?))) +} + +fn record_filesystem_bundle(cache: &Path, identity: &Sha256Hex, archive: &Sha256Hex) -> Result<()> { + let index = cache.join(format!("rootfs-{}.ref", identity.as_str())); + if std::fs::symlink_metadata(&index).is_ok() { + ensure!( + previous_filesystem_bundle(cache, identity)?.is_some(), + "existing filesystem cache index has no verified source" + ); + return Ok(()); + } + let mut pending = tempfile::NamedTempFile::new_in(cache)?; + std::io::Write::write_all( + &mut pending, + format!("{}.mvmpkg\n", archive.as_str()).as_bytes(), + )?; + pending.as_file().sync_all()?; + match pending.persist_noclobber(&index) { + Ok(_) => Ok(()), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + previous_filesystem_bundle(cache, identity)?; + Ok(()) + } + Err(error) => Err(error.error.into()), + } +} + +fn verify_cached_bundle( + path: &Path, + lock: &[u8], + local_tar: Option<&Path>, + pr_test_expected: Option<(u64, &str)>, +) -> Result<()> { + let verified = verified_local_bundle(path)?; let member = verified .manifest .members @@ -237,6 +661,43 @@ fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { )? == lock, "cached composition differs from today's resolved signed inputs" ); + let selected_tar = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some(proof.composition_tar_path.as_str()) + } + _ => None, + }); + ensure!( + selected_tar.map(Path::new) == local_tar, + "cached composition selected a different local base source" + ); + let selected_pr_test = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::EmbeddedCliBootAssets(runtime) => Some(runtime), + _ => None, + }); + ensure!( + selected_pr_test.and_then(|runtime| runtime.pr_test_pull_request) + == pr_test_expected.map(|(pull_request, _)| pull_request), + "cached composition selected a different PR test runtime" + ); + if let Some((_, digest)) = pr_test_expected { + let runtime = selected_pr_test.context("cached PR test runtime declaration missing")?; + ensure!( + verified + .manifest + .find_by_name(&runtime.archive_artifact) + .is_some_and(|artifact| artifact.sha256 == digest), + "cached composition selected a different signed runtime archive" + ); + } Ok(()) } @@ -333,3 +794,76 @@ fn retain_evidence( files, )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_only_preparation_refuses_oversized_recipes_before_cache_access() { + let directory = tempfile::tempdir().expect("recipe directory"); + let recipe = directory.path().join("pack.toml"); + std::fs::write(&recipe, vec![b'x'; 64 * 1024 + 1]).expect("oversized recipe"); + let error = prepare_archive(&recipe, Base::SignedDefault) + .expect_err("oversized recipe cannot become prepared data"); + assert!( + error + .to_string() + .contains("regular file no larger than 64 KiB"), + "{error:#}" + ); + } + + #[cfg(unix)] + #[test] + fn data_only_preparation_refuses_symlink_recipes() { + let directory = tempfile::tempdir().expect("recipe directory"); + let target = directory.path().join("target.toml"); + let recipe = directory.path().join("pack.toml"); + std::fs::write(&target, b"not parsed").expect("target"); + std::os::unix::fs::symlink(&target, &recipe).expect("linked recipe"); + let error = prepare_archive(&recipe, Base::SignedDefault) + .expect_err("linked recipe cannot become prepared data"); + assert!( + error + .to_string() + .contains("regular file no larger than 64 KiB"), + "{error:#}" + ); + } + + #[test] + fn filesystem_index_is_only_a_hint_not_an_archive_authority() { + let cache = tempfile::tempdir().expect("private cache"); + let identity = Sha256Hex::from_bytes(b"ordered filesystem inputs"); + let index = cache + .path() + .join(format!("rootfs-{}.ref", identity.as_str())); + assert!( + previous_filesystem_bundle(cache.path(), &identity) + .expect("absent cache entry is a miss") + .is_none() + ); + for value in [ + "../other.mvmpkg\n".to_owned(), + "A".repeat(64), + format!("{}.mvmpkg\n", "a".repeat(64)), + ] { + std::fs::write(&index, &value).expect("write invalid hint"); + assert!( + previous_filesystem_bundle(cache.path(), &identity).is_err(), + "untrusted hint {value:?} must not authorize reuse" + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + std::fs::remove_file(&index).expect("remove invalid hint"); + symlink(cache.path().join("elsewhere"), &index).expect("link cache hint"); + assert!( + previous_filesystem_bundle(cache.path(), &identity).is_err(), + "a linked index cannot authorize a cached rootfs" + ); + } + } +} diff --git a/crates/mvm-cli/src/commands/vm/exec/composition_request.rs b/crates/mvm-cli/src/commands/vm/exec/composition_request.rs new file mode 100644 index 0000000000..83db8bc5ce --- /dev/null +++ b/crates/mvm-cli/src/commands/vm/exec/composition_request.rs @@ -0,0 +1,294 @@ +//! Run-only composition and SDK transport flags, separate from machine run. + +#[cfg(feature = "pr-test-synthetic-base")] +use anyhow::Context; +use anyhow::Result; +use clap::Args as ClapArgs; +#[cfg(feature = "pr-test-synthetic-base")] +use std::path::Path; +use std::path::PathBuf; + +use super::RunProfile; +use super::{ComposeBase, RunMode, RunVerbArgs, composition}; + +/// The SDK transport surface, carried by `mvmctl run` alone. +/// +/// These are kept out of [`super::RunArgs`] deliberately. `RunArgs` is flattened into +/// both `run` and `machine run` so a shared flag is declared exactly once; +/// `--mode`/`--dev`/`--ack-divergence` are not shared — `machine run` is the +/// beginner contract and has no business growing an SDK transport — so putting +/// them here is what lets the rest be flattened. +#[derive(ClapArgs, Debug, Clone, Default)] +pub(in crate::commands) struct SdkTransportArgs { + /// Assemble a signed, digest-pinned pack recipe and run its entrypoint. + #[arg(long = "compose", value_name = "PACK.TOML", conflicts_with_all = ["mode", "dev"])] + pub compose: Option, + /// Select the locked signed default or an explicitly supplied local base. + #[arg(long = "compose-base", value_enum, requires = "compose")] + pub compose_base: Option, + /// Local schema-3 image-set manifest declaring the selected kernel and tar. + #[arg(long = "compose-base-manifest", requires_all = ["compose", "compose_base_kernel", "compose_base_tar"])] + pub compose_base_manifest: Option, + /// Kernel declared by the local image-set manifest. + #[arg(long = "compose-base-kernel", requires_all = ["compose", "compose_base_manifest", "compose_base_tar"])] + pub compose_base_kernel: Option, + /// Composition tar declared by the local image-set manifest (not its ext4). + #[arg(long = "compose-base-tar", requires_all = ["compose", "compose_base_manifest", "compose_base_kernel"])] + pub compose_base_tar: Option, + /// Test-only, keyless-signed PR runtime quartet directory; requires local development. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(long = "compose-pr-test-runtime-dir", requires_all = ["compose", "compose_pr_test_pull_request"])] + pub compose_pr_test_runtime_dir: Option, + /// Exact PR whose workflow signed the runtime input, never a release identity. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(long = "compose-pr-test-pull-request", requires_all = ["compose", "compose_pr_test_runtime_dir"])] + pub compose_pr_test_pull_request: Option, + /// SDK transport mode for `mvmctl run`. + /// + /// - `--mode plan`: synthesize an ExecutionPlan per Sandbox call + /// and route through `mvm_hostd::supervisor::admit_for_run`; no + /// microVM ever boots. + /// - `--mode live`: spawn the user's script with `MVM_SDK_MODE=live` + /// so the SDK shells each `Sandbox` operation to existing + /// `mvmctl` verbs against a real microVM. + /// - `--mode record` redirects users to `mvmctl dev compile` (where + /// record is the default mode). + /// + /// When unset, the verb behaves as a transient-sandbox runner + /// over the trailing argv. + #[arg(long = "mode", value_enum)] + pub mode: Option, + /// Friendly alias for `--mode live`. + #[arg(long = "dev", conflicts_with_all = ["prod", "mode"])] + pub dev: bool, + /// Acknowledge a divergence class on the plan-mode admission + /// path (repeatable). Unacknowledged divergence refuses + /// admission: what you previewed is not what would ship. + #[arg(long = "ack-divergence", value_name = "KIND")] + pub ack_divergence: Vec, +} + +pub(in crate::commands) fn validate_composition_request( + machine: &crate::commands::machine::MachineRunArgs, +) -> Result<()> { + let run = &machine.run; + super::application_source::require_held_entrypoint_request(run)?; + anyhow::ensure!( + run.source.is_none() + && run.application.is_none() + && run.image.is_none() + && run.manifest.is_none() + && run.flake.is_none() + && run.deployment.is_none() + && run.runtime.is_none() + && !run.runtime_pack + && !run.prod + && run.profile == RunProfile::Dev + && !run.dry_run + && run.launch_plan.is_none() + && run.argv.is_empty() + && !machine.interactive() + && !machine.persistent() + && !machine.attach, + "--compose requires --profile dev and an unambiguous transient run without another boot source or command" + ); + Ok(()) +} + +pub(super) fn prepare_composition( + args: &mut RunVerbArgs, +) -> Result> { + let Some(path) = args.sdk.compose.take() else { + return Ok(None); + }; + validate_composition_request(&args.machine)?; + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic = pr_test_base_input( + std::env::var_os("MVM_PR_TEST_BASE_DIR"), + std::env::var_os("MVM_PR_TEST_PULL_REQUEST"), + )?; + #[cfg(feature = "pr-test-synthetic-base")] + if synthetic.is_some() { + anyhow::ensure!( + args.sdk.compose_base.is_none() + && args.sdk.compose_base_manifest.is_none() + && args.sdk.compose_base_kernel.is_none() + && args.sdk.compose_base_tar.is_none() + && args.machine.run.profile == RunProfile::Dev + && !args.machine.run.prod, + "PR synthetic base requires plain --compose and --profile dev" + ); + } + let base = match args.sdk.compose_base { + Some(ComposeBase::Local) => { + let (Some(manifest), Some(kernel), Some(tar)) = ( + args.sdk.compose_base_manifest.as_deref(), + args.sdk.compose_base_kernel.as_deref(), + args.sdk.compose_base_tar.as_deref(), + ) else { + anyhow::bail!( + "--compose-base local requires --compose-base-manifest, --compose-base-kernel and --compose-base-tar" + ); + }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = match ( + args.sdk.compose_pr_test_runtime_dir.as_deref(), + args.sdk.compose_pr_test_pull_request, + ) { + (Some(directory), Some(pull_request)) => { + anyhow::ensure!( + pull_request > 0 + && args.machine.run.profile == RunProfile::Dev + && !args.machine.run.prod, + "PR test runtime requires --compose-base local, --profile dev and no --prod" + ); + Some(composition::PrTestInput { + directory, + pull_request, + }) + } + (None, None) => None, + _ => anyhow::bail!( + "PR test runtime requires both the quartet directory and pull request" + ), + }; + composition::Base::Local { + manifest, + kernel, + tar, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test, + } + } + Some(ComposeBase::SignedDefault) | None => { + #[cfg(feature = "pr-test-guest-runtime")] + { + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic_selected = synthetic.is_some(); + #[cfg(not(feature = "pr-test-synthetic-base"))] + let synthetic_selected = false; + if !synthetic_selected { + anyhow::ensure!( + args.sdk.compose_pr_test_runtime_dir.is_none() + && args.sdk.compose_pr_test_pull_request.is_none(), + "PR test runtime requires --compose-base local or a PR synthetic base" + ); + } + } + anyhow::ensure!( + args.sdk.compose_base_manifest.is_none() + && args.sdk.compose_base_kernel.is_none() + && args.sdk.compose_base_tar.is_none(), + "local base paths require --compose-base local" + ); + composition::Base::SignedDefault + } + }; + #[cfg(feature = "pr-test-synthetic-base")] + let base = if let Some(fixture) = synthetic { + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = pr_test_runtime_for_fixture(&args.sdk, &fixture)?; + composition::Base::Synthetic { + fixture, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test, + } + } else { + base + }; + let built = composition::prepare(&path, base)?; + args.machine.run.manifest = Some(built.archive().to_string_lossy().into_owned()); + args.machine.entrypoint = true; + Ok(Some(built)) +} + +#[cfg(feature = "pr-test-synthetic-base")] +fn pr_test_base_input( + directory: Option, + number: Option, +) -> Result> { + match (directory, number) { + (None, None) => Ok(None), + (Some(directory), Some(number)) => { + let number = number + .to_str() + .context("PR synthetic base number is not UTF-8")? + .parse::() + .context("PR synthetic base number must be a positive integer")?; + Ok(Some( + mvm_build::pr_test_synthetic_base::PrTestSyntheticBase::new( + Path::new(&directory), + number, + )?, + )) + } + _ => anyhow::bail!("PR synthetic base requires both directory and pull request"), + } +} + +#[cfg(all(feature = "pr-test-synthetic-base", feature = "pr-test-guest-runtime"))] +fn pr_test_runtime_for_fixture<'a>( + sdk: &'a SdkTransportArgs, + fixture: &mvm_build::pr_test_synthetic_base::PrTestSyntheticBase, +) -> Result> { + match ( + sdk.compose_pr_test_runtime_dir.as_deref(), + sdk.compose_pr_test_pull_request, + ) { + (Some(directory), Some(pull_request)) if pull_request == fixture.pull_request() => { + Ok(composition::PrTestInput { + directory, + pull_request, + }) + } + _ => anyhow::bail!( + "PR synthetic base requires a PR test runtime quartet signed for the same pull request" + ), + } +} + +#[cfg(all(test, feature = "pr-test-synthetic-base"))] +mod tests { + use super::pr_test_base_input; + + #[test] + fn pr_test_base_input_is_opt_in_and_refuses_partial_selection() { + assert!(pr_test_base_input(None, None).unwrap().is_none()); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().as_os_str().to_owned(); + assert!(pr_test_base_input(Some(path.clone()), None).is_err()); + assert!(pr_test_base_input(None, Some("42".into())).is_err()); + assert!(pr_test_base_input(Some(path.clone()), Some("0".into())).is_err()); + assert!(pr_test_base_input(Some(path.clone()), Some("invalid".into())).is_err()); + assert_eq!( + pr_test_base_input(Some(path), Some("42".into())) + .unwrap() + .unwrap() + .pull_request(), + 42 + ); + } + + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn synthetic_base_and_pr_runtime_must_name_the_same_signed_pull_request() { + use super::{SdkTransportArgs, pr_test_runtime_for_fixture}; + + let directory = tempfile::tempdir().unwrap(); + let fixture = + mvm_build::pr_test_synthetic_base::PrTestSyntheticBase::new(directory.path(), 42) + .unwrap(); + let mut sdk = SdkTransportArgs::default(); + assert!(pr_test_runtime_for_fixture(&sdk, &fixture).is_err()); + sdk.compose_pr_test_runtime_dir = Some(directory.path().to_path_buf()); + sdk.compose_pr_test_pull_request = Some(43); + assert!(pr_test_runtime_for_fixture(&sdk, &fixture).is_err()); + sdk.compose_pr_test_pull_request = Some(42); + assert_eq!( + pr_test_runtime_for_fixture(&sdk, &fixture) + .unwrap() + .pull_request, + 42 + ); + } +} diff --git a/crates/mvm-cli/src/commands/vm/invoke.rs b/crates/mvm-cli/src/commands/vm/invoke.rs index 77164245c1..da71f4f5da 100644 --- a/crates/mvm-cli/src/commands/vm/invoke.rs +++ b/crates/mvm-cli/src/commands/vm/invoke.rs @@ -35,7 +35,7 @@ use crate::ui; /// lifecycle dispatcher can drive it directly. `machine run --entrypoint` /// builds this from `MachineRunArgs` (source → `source`, `-d`/`--name` → /// `keep_alive`, `--attach` → `attach`); there is no separate `invoke` verb. -pub(in crate::commands) struct EntrypointCall { +pub(in crate::commands) struct EntrypointCall<'a> { /// Template name / pre-built manifest slot to boot, resolved the same way /// as `machine exec --manifest`. With `attach`, this is the running VM's /// name instead. @@ -74,9 +74,8 @@ pub(in crate::commands) struct EntrypointCall { /// shared persistent-machine naming policy. Transient calls leave this /// empty and receive a collision-resistant internal name. pub machine_name: Option, - /// Mark the kept-alive session `mode=dev` so subsequent `session exec` / - /// `run-code` are allowed. No effect without `keep_alive`. - pub keep_alive_dev: bool, + /// Run under Dev admission; kept-alive sessions also retain `mode=dev`. + pub dev_profile: bool, /// Attach this call to an existing warm session id. Accepted but not /// acted on in this build; a non-empty value warns and falls back to the /// transient path. @@ -108,6 +107,8 @@ pub(in crate::commands) struct EntrypointCall { /// Same producer-owned release closure used for health and export. pub portable_boot_assets: Option>, + /// Invocation-private composed boot source, never a registry slot. + pub transient: Option<&'a mvm_client::launch::transient_composition::TransientBoot<'a>>, } /// How a call's stdin is supplied. @@ -163,7 +164,7 @@ impl EntrypointStdin { } } -pub(in crate::commands) fn run_entrypoint(call: EntrypointCall) -> Result<()> { +pub(in crate::commands) fn run_entrypoint(call: EntrypointCall<'_>) -> Result<()> { if call.attach { let exit_code = dispatch_attached(call)?; if exit_code != 0 { @@ -178,7 +179,7 @@ pub(in crate::commands) fn run_entrypoint(call: EntrypointCall) -> Result<()> { /// --name `), reusing its substitution endpoint and boot-minted /// placeholders, and return the entrypoint's exit status. No transient boot, /// no teardown — the VM is the user's to reap. -pub(in crate::commands) fn dispatch_attached(call: EntrypointCall) -> Result { +pub(in crate::commands) fn dispatch_attached(call: EntrypointCall<'_>) -> Result { if call.stdin.is_streaming() { // The grant lives on the plan the *boot* was admitted under, and // that admission happened in whatever process ran `machine run @@ -212,7 +213,7 @@ pub(in crate::commands) fn dispatch_attached(call: EntrypointCall) -> Result Result<()> { +fn run_booted_entrypoint(call: EntrypointCall<'_>) -> Result<()> { let exit_code = boot_entrypoint(call)?; if exit_code != 0 { crate::observability::exit(exit_code); @@ -223,12 +224,12 @@ fn run_booted_entrypoint(call: EntrypointCall) -> Result<()> { /// Boot the slot an entrypoint call names under the admitted path, dispatch /// its baked entrypoint, and return the workload's exit status rather than /// exiting with it. -pub(in crate::commands) fn boot_entrypoint(call: EntrypointCall) -> Result { +pub(in crate::commands) fn boot_entrypoint(call: EntrypointCall<'_>) -> Result { boot_entrypoint_with_application(call, None) } pub(in crate::commands) fn run_application_entrypoint( - call: EntrypointCall, + call: EntrypointCall<'_>, application: mvm_core::registry_pack::application_bundle::ResolvedApplication, ) -> Result<()> { anyhow::ensure!( @@ -243,7 +244,7 @@ pub(in crate::commands) fn run_application_entrypoint( } fn boot_entrypoint_with_application( - call: EntrypointCall, + call: EntrypointCall<'_>, application: Option, ) -> Result { let selected_backend = mvm_client::entrypoint::backend_name_for(call.hypervisor.as_deref())?; @@ -251,7 +252,7 @@ fn boot_entrypoint_with_application( mvm_client::backend_kind_for(&selected_backend) == mvm_core::vm_backend::BackendKind::Hvf; anyhow::ensure!( !native - || (application.is_some() + || ((application.is_some() || call.transient.is_some()) && !call.keep_alive && !call.attach && !call.stdin.is_streaming() @@ -259,7 +260,7 @@ fn boot_entrypoint_with_application( && !call.reset && call.r#fn.is_none() && call.portable_boot_assets.is_none()), - "native entrypoint requires a fresh installed application with original signed boot inputs and one-shot stdin" + "native entrypoint requires a fresh signed application or transient composition with one-shot stdin" ); if let Some(application) = &application { anyhow::ensure!( @@ -269,9 +270,16 @@ fn boot_entrypoint_with_application( } warn_accepted_but_inert(&call); - // The entrypoint action targets a manifest slot, resolved through the same - // helper as `machine exec --manifest`. - let slot = mvm_client::entrypoint::resolve_slot(WorkloadSource::Manifest(&call.source))?; + let slot = if let Some(boot) = call.transient { + boot.reverify()?; + anyhow::ensure!( + call.source == boot.sha256(), + "transient composition selection changed" + ); + call.source.clone() + } else { + mvm_client::entrypoint::resolve_slot(WorkloadSource::Manifest(&call.source))? + }; let producer_identity = native .then(crate::commands::load_enrolled_identity) .transpose()?; @@ -288,7 +296,11 @@ fn boot_entrypoint_with_application( .transpose()? .into_iter() .collect(); - let bundle_posture = mvm_client::installed_bundle_posture(&call.source)?; + let bundle_posture = if let Some(boot) = call.transient { + boot.manifest().security_posture().copied() + } else { + mvm_client::installed_bundle_posture(&call.source)? + }; let lifecycle_label = if call.keep_alive { "warm session" } else { @@ -310,7 +322,7 @@ fn boot_entrypoint_with_application( .secrets(secrets) .agent_verb_override(call.agent_verb_override) .caller_commitment(call.caller_commitment) - .dev(call.keep_alive_dev) + .dev(call.dev_profile) .network_policy(call.network_policy) .assets(assets) .bundle_posture(bundle_posture); @@ -322,7 +334,7 @@ fn boot_entrypoint_with_application( .build()?; let lifecycle = if call.keep_alive { CallLifecycle::KeepAlive { - mode: if call.keep_alive_dev { + mode: if call.dev_profile { mvm_core::session::SessionMode::Dev } else { mvm_core::session::SessionMode::Prod @@ -346,6 +358,7 @@ fn boot_entrypoint_with_application( memory_mib: call.memory_mib, admission, portable_boot_assets: call.portable_boot_assets.as_deref(), + transient: call.transient, }, stdin: call.stdin.into_call_stdin(), timeout_secs: call.timeout, @@ -432,7 +445,7 @@ fn run_native_entrypoint( /// Say which accepted flags this build does not act on, rather than ignoring /// them silently. -fn warn_accepted_but_inert(call: &EntrypointCall) { +fn warn_accepted_but_inert(call: &EntrypointCall<'_>) { if call.reset { ui::warn( "--reset is accepted but not acted on in this build; treating as default behaviour", @@ -2110,7 +2123,7 @@ mod streamed_stdin_tests { use super::*; use mvm_client::entrypoint::stdin_stream::StreamedInputReport; - fn streaming_attach_call() -> EntrypointCall { + fn streaming_attach_call() -> EntrypointCall<'static> { EntrypointCall { source: "already-running".to_string(), stdin: EntrypointStdin::Streaming, @@ -2125,7 +2138,7 @@ mod streamed_stdin_tests { machine_name: None, reset: false, keep_alive: false, - keep_alive_dev: false, + dev_profile: false, session: None, r#fn: None, attach: true, @@ -2135,6 +2148,7 @@ mod streamed_stdin_tests { network_policy: mvm_core::network_policy::NetworkPolicy::deny_all(), hypervisor: None, portable_boot_assets: None, + transient: None, } } diff --git a/crates/mvm-cli/src/commands/vm/mod.rs b/crates/mvm-cli/src/commands/vm/mod.rs index f71584d663..eebdde3cea 100644 --- a/crates/mvm-cli/src/commands/vm/mod.rs +++ b/crates/mvm-cli/src/commands/vm/mod.rs @@ -15,6 +15,7 @@ pub(in crate::commands) mod replay; pub(in crate::commands) mod workspace_apply; use mvm_client::admission::entrypoint_resolve; pub(super) mod exec; +pub(in crate::commands) use exec::prepare_local_pack_data; pub(in crate::commands) mod explain; pub(super) mod forward; pub(super) mod fs; diff --git a/crates/mvm-cli/src/commands/vm/session.rs b/crates/mvm-cli/src/commands/vm/session.rs index d1b7df4fce..bab8129997 100644 --- a/crates/mvm-cli/src/commands/vm/session.rs +++ b/crates/mvm-cli/src/commands/vm/session.rs @@ -722,6 +722,7 @@ fn cmd_start(args: StartArgs) -> Result<()> { memory_mib: args.memory_mib, admission, portable_boot_assets: None, + transient: None, }, mode, idle_timeout_secs: args.idle_timeout, diff --git a/crates/mvm-cli/src/exec.rs b/crates/mvm-cli/src/exec.rs index 52b5c6caef..0339424cf1 100644 --- a/crates/mvm-cli/src/exec.rs +++ b/crates/mvm-cli/src/exec.rs @@ -1359,6 +1359,7 @@ pub fn resolve_launch( assets: shape.assets, volumes: &start_config.volumes, bundle_archive: bundle_archive.as_deref(), + transient: None, runtime_config: Some(&start_config), })? { diff --git a/crates/mvm-cli/src/lib.rs b/crates/mvm-cli/src/lib.rs index c6fe08be22..e51ef5f786 100644 --- a/crates/mvm-cli/src/lib.rs +++ b/crates/mvm-cli/src/lib.rs @@ -65,3 +65,5 @@ pub mod boot_policy { pub mod plan_builder { pub use mvm_core::plan::{SynthesisInput, synthesize_plan}; } +#[cfg(all(feature = "pr-test-synthetic-base", feature = "release-channel"))] +compile_error!("pr-test-synthetic-base is unavailable with release-channel"); diff --git a/crates/mvm-cli/src/pack_registry.rs b/crates/mvm-cli/src/pack_registry.rs index fb5e8ca938..199b66dabe 100644 --- a/crates/mvm-cli/src/pack_registry.rs +++ b/crates/mvm-cli/src/pack_registry.rs @@ -19,14 +19,20 @@ use mvm_core::registry_pack::application_bundle::{ ApplicationInstallStore, InstalledRegistryApplication, install_application_bundle, verify_application_bundle, }; +use mvm_core::registry_pack::application_component::{ + ComponentArchive, VerifiedApplicationComponentInputs, verify_application_component_inputs, +}; +use mvm_core::registry_pack::component_layer::{ + VerifiedComponentLayerInputs, verify_component_layer_inputs, +}; use mvm_core::registry_pack::runtime_layer::{ VerifiedRuntimeLayerInputs, verify_runtime_layer_inputs, }; use mvm_core::registry_pack::{ AuthenticatedRegistryManifest, InstalledRegistryPack, PackAdoption, PackLockfile, PackPin, - PackReference, RegistryPackImage, RegistryPackVerification, VerifiedRegistryApplication, - VerifiedRegistryManifest, VerifiedRegistryPack, adopt_registry_manifest, - verify_registry_manifest, + PackReference, RegistryPackFile, RegistryPackImage, RegistryPackVerification, + VerifiedRegistryApplication, VerifiedRegistryManifest, VerifiedRegistryPack, + adopt_registry_manifest, verify_registry_manifest, }; use mvm_core::registry_pack_store::{ PackPolicyDocument, adopt_install_and_pin, check_registry_pack_revocations_if_configured, @@ -139,6 +145,108 @@ pub fn acquire_runtime_layer( Ok(held) } +/// Private, verified component inputs; neither variant installs or admits a layer. +#[derive(Debug)] +pub enum AcquiredComponentInputs { + Application(VerifiedApplicationComponentInputs), + Layer(VerifiedComponentLayerInputs), +} + +/// Acquire exactly the graph's signed component pin without consulting the catalog. +/// +/// # Errors +/// +/// Refuses drift, untrusted metadata, stale or revoked trust, unsupported +/// sources, and any archive or policy bytes differing from the signed descriptor. +pub fn acquire_component_inputs( + config: &PackRegistryConfig, + expected: &PackPin, +) -> Result { + let fetched = download_pack(config, expected.reference())?; + let policy = load_publisher_policy_or_official_default( + &mvm_core::config::registry_pack_publisher_policy_path(), + )? + .policy; + let lock = PackLockfile::new(vec![expected.clone()])?; + let verified = verify_registry_manifest(&RegistryPackVerification::new( + expected.reference(), + &fetched.manifest_bytes, + &fetched.signature_bundle, + &lock, + &policy, + ))?; + if verified.reference().namespace() == "mvm" + || verified.signer().identity == mvm_core::registry_pack::OFFICIAL_PACK_SIGNING_IDENTITY + { + crate::official_revocations::ensure_official_feed_fresh(); + } + let (source, files) = match &verified { + VerifiedRegistryManifest::ApplicationComponent(component) => { + component.check_current_revocations()?; + ( + &component.manifest().application_component.archive, + component.manifest().files.as_slice(), + ) + } + VerifiedRegistryManifest::ComponentLayer(component) => { + component.check_current_revocations()?; + ( + &component.manifest().component().archive, + component.manifest().files(), + ) + } + _ => bail!("pinned pack is not an application, runtime or tool component"), + }; + let archive = stage_component_inputs(config, &fetched, source, files)?; + match verified { + VerifiedRegistryManifest::ApplicationComponent(component) => { + Ok(AcquiredComponentInputs::Application( + verify_application_component_inputs(&component, &archive, fetched.staged_path())?, + )) + } + VerifiedRegistryManifest::ComponentLayer(component) => Ok(AcquiredComponentInputs::Layer( + verify_component_layer_inputs(&component, &archive, fetched.staged_path())?, + )), + _ => unreachable!("component kind was checked before staging"), + } +} + +fn stage_component_inputs( + config: &PackRegistryConfig, + fetched: &FetchedPack, + source: &ComponentArchive, + files: &[RegistryPackFile], +) -> Result { + if !supported_runtime_layer_source(&source.source) { + bail!("component source is not a supported pinned HTTPS release asset"); + } + stage_component_policies(config, fetched, files)?; + let archive = fetched.staged_path().join("component.tar"); + download_payload_file(&source.source, &archive, source.archive_size, true)?; + Ok(archive) +} + +fn stage_component_policies( + config: &PackRegistryConfig, + fetched: &FetchedPack, + files: &[RegistryPackFile], +) -> Result<()> { + for file in files { + let target = fetched.staged_path().join(&file.path); + std::fs::create_dir_all(target.parent().context("component policy has no parent")?)?; + let url = pack_url( + config, + &fetched.reference, + &format!("{FILES_DIR}/{}", file.path), + ); + download_payload_file(&url, &target, file.size, false)?; + if Sha256Hex::from_bytes(&std::fs::read(&target)?) != file.sha256 { + bail!("component policy bytes differ from signed descriptor"); + } + } + Ok(()) +} + fn supported_runtime_layer_source(source: &str) -> bool { let prefix = "/tinylabscom/mvm-packs/releases/download/"; mvm_http::Url::parse(source).is_ok_and(|url| { @@ -603,6 +711,12 @@ fn pull_one( // authenticated bytes and digest for that later transaction. return Ok((VerifiedPull::Application(Box::new(application)), Vec::new())); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + bail!("portable application component acquisition requires verified archive custody"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + bail!("runtime/tool component acquisition requires verified archive custody"); + } VerifiedRegistryManifest::RuntimeLayer(_) => { bail!("runtime layer acquisition requires verified archive custody"); } @@ -1072,6 +1186,82 @@ mod tests { assert_eq!(std::fs::read_dir(fetched.staged_path()).unwrap().count(), 0); } + #[test] + fn component_policy_staging_uses_the_exact_version_and_private_directory() { + let registry = tempfile::tempdir().unwrap(); + let reference: PackReference = "runtime/python@1.0.0".parse().unwrap(); + let pack = registry + .path() + .join("packs/runtime/python/1.0.0/files/pack"); + std::fs::create_dir_all(&pack).unwrap(); + std::fs::write(pack.join("group.toml"), b"group = 1").unwrap(); + let config = PackRegistryConfig { + registry_url: format!("file://{}", registry.path().display()), + }; + let fetched = FetchedPack { + reference, + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let file = RegistryPackFile { + path: "pack/group.toml".into(), + sha256: Sha256Hex::from_bytes(b"group = 1"), + size: 9, + }; + stage_component_policies(&config, &fetched, std::slice::from_ref(&file)).unwrap(); + assert_eq!( + std::fs::read(fetched.staged_path().join(&file.path)).unwrap(), + b"group = 1" + ); + std::fs::write(pack.join("group.toml"), b"changed!!").unwrap(); + let second = FetchedPack { + reference: fetched.reference.clone(), + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let error = stage_component_policies(&config, &second, &[file]).unwrap_err(); + assert!(error.to_string().contains("differ"), "{error}"); + } + + #[test] + fn component_acquisition_refuses_a_drifted_graph_pin_before_payload() { + let manifest = br#"{"schema_version":4,"kind":"tool_component"}"#; + let (registry, config) = file_registry(manifest); + let reference: PackReference = "runtime/python@1.2.3".parse().unwrap(); + let pin = PackPin::new(reference, Sha256Hex::from_bytes(b"older signed manifest")).unwrap(); + let error = acquire_component_inputs(&config, &pin).unwrap_err(); + let text = format!("{error:#}"); + assert!(text.contains("digest") || text.contains("hash"), "{text}"); + assert!( + !registry + .path() + .join("packs/runtime/python/1.2.3/files") + .exists() + ); + } + + #[test] + fn component_source_is_refused_before_policy_or_archive_fetch() { + let config = PackRegistryConfig { + registry_url: "file:///nonexistent-registry".into(), + }; + let fetched = FetchedPack { + reference: "runtime/python@1.0.0".parse().unwrap(), + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let source = ComponentArchive { + source: "https://attacker.example.invalid/layer.tar".into(), + archive_sha256: Sha256Hex::from_bytes(b"archive"), + archive_size: 7, + }; + assert!(stage_component_inputs(&config, &fetched, &source, &[]).is_err()); + assert_eq!(std::fs::read_dir(fetched.staged_path()).unwrap().count(), 0); + } + #[test] fn built_image_verifier_refusal_precedes_payload_download() { use mvm_core::image_set::{ArtifactName, ReleaseTag, RepositorySlug}; diff --git a/crates/mvm-client/Cargo.toml b/crates/mvm-client/Cargo.toml index 364311c47c..e91304c34a 100644 --- a/crates/mvm-client/Cargo.toml +++ b/crates/mvm-client/Cargo.toml @@ -52,6 +52,11 @@ clap = ["dep:clap"] # checks signed artifacts and keyless instruction-file signatures. Explicit # no-default-features builds report signatures as uncheckable, never verified. manifest-verify = ["mvm-core/manifest-verify"] +pr-test-guest-runtime = [ + "mvm-runtime/pr-test-guest-runtime", + "mvm-build/pr-test-guest-runtime", + "mvm-hostd/pr-test-guest-runtime", +] # Derive schemas for authored policy and instruction trust policy, and build # their emitters. Off by default so no shipped closure carries schemars; # mvm-contract/schema covers policy endpoint routes and backend kinds. diff --git a/crates/mvm-client/src/admission/bundle_admission_tests.rs b/crates/mvm-client/src/admission/bundle_admission_tests.rs index 7064a88a25..9714000ee5 100644 --- a/crates/mvm-client/src/admission/bundle_admission_tests.rs +++ b/crates/mvm-client/src/admission/bundle_admission_tests.rs @@ -108,6 +108,86 @@ fn a_host_signed_local_build_is_audited_with_its_origin_and_digest() { assert!(chain.contains("local-build"), "{chain}"); } +#[test] +fn transient_composition_admits_once_without_enrolling_or_installing() { + let home = tempfile::tempdir().expect("mvm home"); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let (archive, _) = + crate::launch::transient_composition::tests::signed_local_composition(home.path()); + let mut staged = + crate::launch::transient_composition::TransientComposition::stage(&archive).unwrap(); + let source = staged.consume().unwrap(); + let rootfs = source.rootfs().unwrap(); + let kernel = source.kernel().unwrap(); + let config = mvm_core::vm_backend::VmStartConfig { + name: "vm-pinned".into(), + rootfs_path: rootfs.to_string_lossy().into_owned(), + kernel_path: Some(kernel.to_string_lossy().into_owned()), + kernel_read_sha256: Some(mvm_core::crypto::image_verify::sha256_file(&kernel).unwrap()), + ..Default::default() + }; + let ledger = InMemoryNonceLedger::new(); + let mut ordinary = pinning_params(&rootfs, &ledger); + ordinary.kernel_path = Some(&kernel); + ordinary.backend_kind = Some(mvm_core::vm_backend::BackendKind::Firecracker); + ordinary.bundle_pin = Some(BundlePin::boots(source.archive())); + ordinary.restrict_agent_verbs = false; + ordinary.runtime_config = Some(&config); + let error = admit_plan_for_boot(ordinary).expect_err("private bytes are not enrollment"); + assert!( + format!("{error:#}").contains("trust store has no entry"), + "{error:#}" + ); + + let mut prod = pinning_params(&rootfs, &ledger); + prod.kernel_path = Some(&kernel); + prod.backend_kind = Some(mvm_core::vm_backend::BackendKind::Firecracker); + prod.bundle_pin = Some(BundlePin::transient(&source)); + prod.restrict_agent_verbs = true; + prod.runtime_config = Some(&config); + let error = admit_plan_for_boot(prod).expect_err("transient proof is Dev-only"); + assert!( + format!("{error:#}").contains("cannot authorize this admission"), + "{error:#}" + ); + + let original = std::fs::read(source.archive()).unwrap(); + std::fs::write(source.archive(), b"tampered").unwrap(); + assert!(source.reverify().is_err(), "private archive is rechecked"); + std::fs::write(source.archive(), original).unwrap(); + let mut params = pinning_params(&rootfs, &ledger); + params.kernel_path = Some(&kernel); + params.backend_kind = Some(mvm_core::vm_backend::BackendKind::Firecracker); + params.bundle_pin = Some(BundlePin::transient(&source)); + params.restrict_agent_verbs = false; + params.runtime_config = Some(&config); + + let admitted = admit_plan_for_boot(params).expect("private Dev boot admits"); + assert_eq!( + admitted.admitted.plan().audit_labels["origin"], + "local-build" + ); + assert!( + !mvm_core::plan::BundleRegistry::default_path() + .unwrap() + .root() + .exists() + ); + + let mut replay = pinning_params(&rootfs, &ledger); + replay.kernel_path = Some(&kernel); + replay.backend_kind = Some(mvm_core::vm_backend::BackendKind::Firecracker); + replay.bundle_pin = Some(BundlePin::transient(&source)); + replay.restrict_agent_verbs = false; + replay.runtime_config = Some(&config); + let error = admit_plan_for_boot(replay).expect_err("transient permit cannot be replayed"); + assert!( + format!("{error:#}").contains("already admitted"), + "{error:#}" + ); +} + #[test] fn a_host_signed_local_build_is_refused_by_production_admission() { let home = tempfile::tempdir().expect("mvm home"); diff --git a/crates/mvm-client/src/admission/bundle_binding.rs b/crates/mvm-client/src/admission/bundle_binding.rs index 7741e3d284..f7f63403a4 100644 --- a/crates/mvm-client/src/admission/bundle_binding.rs +++ b/crates/mvm-client/src/admission/bundle_binding.rs @@ -33,6 +33,7 @@ pub struct BundlePin<'a> { archive: &'a Path, lineage: BundleLineage<'a>, production: bool, + transient: Option<&'a crate::launch::transient_composition::TransientBoot<'a>>, } #[derive(Debug, Clone, Copy)] @@ -51,6 +52,17 @@ impl<'a> BundlePin<'a> { archive, lineage: BundleLineage::Boots, production: false, + transient: None, + } + } + + /// One invocation-private Dev boot, not a publisher enrollment or installation. + pub fn transient(source: &'a crate::launch::transient_composition::TransientBoot<'a>) -> Self { + Self { + archive: source.archive(), + lineage: BundleLineage::Boots, + production: false, + transient: Some(source), } } @@ -61,6 +73,7 @@ impl<'a> BundlePin<'a> { archive, lineage: BundleLineage::Boots, production: true, + transient: None, } } @@ -72,6 +85,12 @@ impl<'a> BundlePin<'a> { pub(super) fn is_production(&self) -> bool { self.production } + + pub(super) fn transient_source( + &self, + ) -> Option<&crate::launch::transient_composition::TransientBoot<'_>> { + self.transient + } } /// The bundle a parent's admitted plan names, for a child forked or restored @@ -135,6 +154,7 @@ impl InheritedBundle { bundle_sha256: &self.bundle_sha256, }, production: false, + transient: None, } } } diff --git a/crates/mvm-client/src/admission/mod.rs b/crates/mvm-client/src/admission/mod.rs index 4ed2d542ec..0e8ad5dbe6 100644 --- a/crates/mvm-client/src/admission/mod.rs +++ b/crates/mvm-client/src/admission/mod.rs @@ -519,6 +519,7 @@ fn admit( let prepared = pinned_bundle::prepare_pinned_bundle(pinned_bundle::PreparePinnedBundleParams { pin, + dev: !p.restrict_agent_verbs, keys_dir: p.keys_dir, backend_kind: p.backend_kind, image_support: bundle_image_support.as_ref(), diff --git a/crates/mvm-client/src/admission/pinned_bundle.rs b/crates/mvm-client/src/admission/pinned_bundle.rs index 4b9865be4c..8293f6df1b 100644 --- a/crates/mvm-client/src/admission/pinned_bundle.rs +++ b/crates/mvm-client/src/admission/pinned_bundle.rs @@ -14,6 +14,7 @@ pub(super) type BundleTrust = pub(super) struct PreparePinnedBundleParams<'a> { pub pin: BundlePin<'a>, + pub dev: bool, pub keys_dir: Option<&'a std::path::Path>, pub backend_kind: Option, pub image_support: Option<&'a BackendImageSupport>, @@ -53,10 +54,6 @@ pub(super) fn prepare_pinned_bundle( let trust = BundleTrust::new(release_trust, host_key_id.clone(), host_signer.verifying); let verified = mvm_core::plan::read_and_verify_bundle(&bytes, &trust) .with_context(|| format!("verifying bundle at {}", path.display()))?; - mvm_core::plan::bundle::require_composable_local_enrollment( - &verified.manifest, - &FsTrustStore::default_path()?, - )?; let origin = bundle_origin(&verified.manifest, &host_key_id, trust.release()); match origin { BundleOrigin::LocalBuild => anyhow::ensure!( @@ -69,6 +66,24 @@ pub(super) fn prepare_pinned_bundle( path.display() ), } + if let Some(source) = pin.transient_source() { + anyhow::ensure!( + params.dev + && !pin.is_production() + && origin == BundleOrigin::LocalBuild + && source.archive() == path + && source.sha256() == mvm_core::plan::bundle_sha256(&bytes) + && source.manifest() == &verified.manifest, + "transient composition cannot authorize this admission" + ); + source.reverify()?; + source.admit_once()?; + } else { + mvm_core::plan::bundle::require_composable_local_enrollment( + &verified.manifest, + &FsTrustStore::default_path()?, + )?; + } if !verified.embedded_image_sets.is_empty() { let kind = params.backend_kind.ok_or_else(|| { diff --git a/crates/mvm-client/src/builder_bundle.rs b/crates/mvm-client/src/builder_bundle.rs index 28a5872968..aabb556ca0 100644 --- a/crates/mvm-client/src/builder_bundle.rs +++ b/crates/mvm-client/src/builder_bundle.rs @@ -164,6 +164,7 @@ pub fn export_builder_result( boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, supplemental_artifacts: &[], local_composition: None, out: &request.bundle_out, diff --git a/crates/mvm-client/src/entrypoint/admission.rs b/crates/mvm-client/src/entrypoint/admission.rs index 85d933bd5e..159578b408 100644 --- a/crates/mvm-client/src/entrypoint/admission.rs +++ b/crates/mvm-client/src/entrypoint/admission.rs @@ -140,6 +140,7 @@ impl EntrypointAdmission { assets, volumes, bundle_archive, + transient, runtime_config, } = inputs; let ledger = mvm_hostd::plan_admission::InMemoryNonceLedger::default(); @@ -171,7 +172,15 @@ impl EntrypointAdmission { keys_dir: None, audit_dir: None, policy_dir: None, - bundle_pin: bundle_archive.map(crate::admission::BundlePin::boots), + bundle_pin: if let Some(source) = transient { + anyhow::ensure!( + self.dev && bundle_archive == Some(source.archive()), + "transient composition requires the held Dev bundle" + ); + Some(crate::admission::BundlePin::transient(source)) + } else { + bundle_archive.map(crate::admission::BundlePin::boots) + }, bundle_posture: self.bundle_posture, deps_volume: None, shares: mvm_hostd::run::shares_from_vm_volumes(volumes), @@ -406,6 +415,7 @@ mod tests { assets: &[], volumes: &[], bundle_archive: None, + transient: None, runtime_config: None, } } @@ -772,6 +782,7 @@ mod stdin_grant_tests { assets: &[], volumes: &[], bundle_archive: None, + transient: None, runtime_config: None, }) } diff --git a/crates/mvm-client/src/entrypoint/boot.rs b/crates/mvm-client/src/entrypoint/boot.rs index 97a54feb4f..0862affc79 100644 --- a/crates/mvm-client/src/entrypoint/boot.rs +++ b/crates/mvm-client/src/entrypoint/boot.rs @@ -518,6 +518,8 @@ pub struct AdmitInputs<'a> { /// archive, or the extracted files beside it, no longer match what the /// publisher signed. pub bundle_archive: Option<&'a std::path::Path>, + /// Invocation-private custody in place of an installed bundle address. + pub transient: Option<&'a crate::launch::transient_composition::TransientBoot<'a>>, /// The selected runtime artifacts and their untrusted release proof. /// Admission verifies this same config before the backend consumes it. pub runtime_config: Option<&'a VmStartConfig>, @@ -547,6 +549,7 @@ impl SessionVmName<'_> { /// on which backend. pub struct SessionBoot<'a> { slot: &'a str, + transient: Option<&'a crate::launch::transient_composition::TransientBoot<'a>>, vm_name: SessionVmName<'a>, cpus: u32, memory_mib: u32, @@ -562,6 +565,7 @@ impl<'a> SessionBoot<'a> { pub fn builder(slot: &'a str, vm_name: SessionVmName<'a>) -> SessionBootBuilder<'a> { SessionBootBuilder(Self { slot, + transient: None, vm_name, cpus: 1, memory_mib: 256, @@ -576,6 +580,16 @@ impl<'a> SessionBoot<'a> { pub struct SessionBootBuilder<'a>(SessionBoot<'a>); impl<'a> SessionBootBuilder<'a> { + /// Use a verified invocation-private composition instead of a stored slot. + #[must_use] + pub fn transient( + mut self, + source: Option<&'a crate::launch::transient_composition::TransientBoot<'a>>, + ) -> Self { + self.0.transient = source; + self + } + /// Producer health may select an already verified release closure. This is /// not a consumer acquisition path or an admission grant. #[must_use] @@ -721,6 +735,7 @@ fn boot_session_core( ) -> Result { let SessionBoot { slot, + transient, vm_name, cpus, memory_mib, @@ -728,10 +743,39 @@ fn boot_session_core( backend_name, portable_boot_assets, } = boot; - let (spec, vmlinux, initrd, rootfs, rev) = - mvm_runtime::vm::template::lifecycle::template_artifacts_for_boot(slot) - .with_context(|| format!("Loading template '{slot}'"))?; - let bundle_archive = mvm_runtime::vm::template::lifecycle::installed_bundle_archive(slot)?; + let (vmlinux, initrd, rootfs, rev, flake_ref, profile, bundle_archive) = + if let Some(source) = transient { + source.reverify()?; + anyhow::ensure!( + source.sha256() == slot, + "transient composition differs from selected entrypoint" + ); + ( + source.kernel()?.to_string_lossy().into_owned(), + source + .initrd() + .map(|path| path.to_string_lossy().into_owned()), + source.rootfs()?.to_string_lossy().into_owned(), + source.sha256().to_owned(), + "transient-composition".to_owned(), + "dev".to_owned(), + Some(source.archive().to_path_buf()), + ) + } else { + let (spec, vmlinux, initrd, rootfs, rev) = + mvm_runtime::vm::template::lifecycle::template_artifacts_for_boot(slot) + .with_context(|| format!("Loading template '{slot}'"))?; + let archive = mvm_runtime::vm::template::lifecycle::installed_bundle_archive(slot)?; + ( + vmlinux, + initrd, + rootfs, + rev, + spec.flake_ref, + spec.profile, + archive, + ) + }; let backend = resolve_backend(backend_name)?; let vm_name = vm_name.resolve(); let (verity_path, roothash) = mvm_runtime::microvm::probe_verity_sidecar(&rootfs); @@ -744,8 +788,8 @@ fn boot_session_core( verity_path, roothash, revision_hash: rev, - flake_ref: spec.flake_ref, - profile: Some(spec.profile), + flake_ref, + profile: Some(profile), cpus, memory_mib, // Session VMs are short-lived boots; balloon elasticity isn't useful @@ -763,7 +807,17 @@ fn boot_session_core( // The runtime overlay is the single source of the guest agent and its // helpers here, as on the transient path; a missing required overlay is // built or acquired, never replaced by a copy baked into the rootfs. - if let Some(assets) = portable_boot_assets { + if let Some(source) = transient { + anyhow::ensure!( + portable_boot_assets.is_none(), + "transient composition cannot replace its signed boot closure" + ); + crate::launch::bundle_runtime::attach_transient_bundle( + &mut start_config, + backend.name(), + source, + )?; + } else if let Some(assets) = portable_boot_assets { anyhow::ensure!( bundle_archive.is_none(), "producer health cannot replace an installed bundle's boot closure" @@ -791,6 +845,7 @@ fn boot_session_core( assets: &[], volumes: &start_config.volumes, bundle_archive: bundle_archive.as_deref(), + transient, runtime_config: Some(&start_config), })?; let (mut substrate, context) = match admitted { @@ -816,6 +871,9 @@ fn boot_session_core( if let Some(runtime) = &_built_runtime { runtime.verify_config(&start_config)?; } + if let Some(source) = transient { + source.reverify()?; + } if mvm_runtime::catalog::descriptor(backend.kind()).is_workload { mvm_hostd::plan_admission::stash_plan_for_bridge(&start_config) .context("persisting admitted session plan before backend start")?; diff --git a/crates/mvm-client/src/entrypoint/call.rs b/crates/mvm-client/src/entrypoint/call.rs index c5bfb20d30..0c5988d044 100644 --- a/crates/mvm-client/src/entrypoint/call.rs +++ b/crates/mvm-client/src/entrypoint/call.rs @@ -51,6 +51,8 @@ pub struct EntrypointVm<'a> { /// Producer-owned release inputs for health. Ordinary consumers leave this /// unset and may not use it to repair an incomplete installed archive. pub portable_boot_assets: Option<&'a crate::portable_boot_assets::PreparedPortableBootAssets>, + /// Invocation-private composed source, never a stored workload slot. + pub transient: Option<&'a crate::launch::transient_composition::TransientBoot<'a>>, } /// A booted entrypoint VM and the admission it booted under. @@ -462,6 +464,7 @@ impl OwnedTransientResult { memory_mib: vm.memory_mib, admission: vm.admission, portable_boot_assets: vm.portable_boot_assets, + transient: vm.transient, }, pair, )?); @@ -544,6 +547,7 @@ pub fn boot_entrypoint_vm_owned( .network_policy(vm.admission.network_policy().clone()) .backend_name(&backend_name) .portable_boot_assets(vm.portable_boot_assets) + .transient(vm.transient) .build(); let owner = super::boot::boot_session_vm_owned(boot, &vm.admission, pair)?; emit_launched(&owner.resources().admission, &backend_name, true); @@ -587,6 +591,7 @@ pub fn boot_entrypoint_vm( memory_mib, admission, portable_boot_assets, + transient, } = vm; let backend_name = admission.backend_name().to_string(); let admitted: RefCell> = RefCell::new(None); @@ -601,6 +606,7 @@ pub fn boot_entrypoint_vm( .network_policy(admission.network_policy().clone()) .backend_name(&backend_name) .portable_boot_assets(portable_boot_assets) + .transient(transient) .build(); match boot_session_vm(boot, &admit, pair) { Ok(vm) => { @@ -689,6 +695,11 @@ fn run_entrypoint_call_recording( let streams_stdin = stdin.is_streaming(); vm.admission = vm.admission.with_stream_stdin(streams_stdin); let slot = vm.slot; + let private = vm.transient.is_some(); + anyhow::ensure!( + !private || matches!(lifecycle, CallLifecycle::Transient), + "transient composition cannot become a kept-alive session" + ); let backend_name = vm.admission.backend_name().to_string(); let named = vm.vm_name.resolve(); observer.vm_named(&named); @@ -700,6 +711,7 @@ fn run_entrypoint_call_recording( memory_mib: vm.memory_mib, admission: vm.admission, portable_boot_assets: vm.portable_boot_assets, + transient: vm.transient, }, pair, )?; @@ -710,7 +722,11 @@ fn run_entrypoint_call_recording( CallLifecycle::Transient => SessionMode::Prod, CallLifecycle::KeepAlive { mode } => mode, }; - let session_id = register_call_session(&vm_name, slot, mode); + let session_id = if private { + None + } else { + register_call_session(&vm_name, slot, mode) + }; // Agent-wait teardown is best-effort and does not return its stop result. // On failure retain the session record: removing it would hide a VM whose // stop is unconfirmed. This path emits no stop-acknowledgement observation. @@ -987,6 +1003,7 @@ mod tests { memory_mib: 512, admission: EntrypointAdmission::builder("hvf").build().unwrap(), portable_boot_assets: None, + transient: None, }, stdin: CallStdin::Streaming(Box::new(std::io::Cursor::new(b"must-not-read"))), timeout_secs: 1, @@ -1221,6 +1238,7 @@ mod tests { memory_mib: 256, admission, portable_boot_assets: None, + transient: None, }, stdin: CallStdin::OneShot(b"[[], {}]".to_vec()), timeout_secs: 5, diff --git a/crates/mvm-client/src/entrypoint/caller_registration_live_hvf.rs b/crates/mvm-client/src/entrypoint/caller_registration_live_hvf.rs index 021ed892d7..1a703c5d67 100644 --- a/crates/mvm-client/src/entrypoint/caller_registration_live_hvf.rs +++ b/crates/mvm-client/src/entrypoint/caller_registration_live_hvf.rs @@ -341,6 +341,7 @@ fn boot(inputs: &Inputs, vm: &mut OwnedVm, opted_in: bool) -> Result Result<()> { memory_mib: 4096, admission: policy, portable_boot_assets: None, + transient: None, }, None, )?); diff --git a/crates/mvm-client/src/guest.rs b/crates/mvm-client/src/guest.rs index 625d44e19d..cafd87ea34 100644 --- a/crates/mvm-client/src/guest.rs +++ b/crates/mvm-client/src/guest.rs @@ -178,6 +178,35 @@ pub fn start_process(name: &str, start: ProcStart) -> Result { } } +const LOCAL_PACK_ENTRYPOINT: &str = "/etc/mvm/entrypoint"; + +/// Start the sealed local-pack entrypoint without substituting an ad-hoc command. +/// +/// The guest's detached worker sends its output to the VM console and reports +/// its exit to the host. An acknowledged spawn is not proof that the workload +/// produced output or stayed healthy. +/// +/// # Errors +/// Refuses an invalid name, an unservable guest, or a denied or failed spawn. +pub fn start_local_pack_entrypoint(name: &str) -> Result { + validate(name)?; + let request = local_pack_entrypoint_request(name); + emit_vsock_rpc_audit(name, &request); + let GuestRequest::RunDetached { argv, env } = request else { + unreachable!("fixed local pack entrypoint request") + }; + let pid = mvm_agentd::vsock::send_run_detached(&mut connect(name)?, argv, env)?; + mvm_core::audit_emit!(VmProcStart, vm: name, "argv0={LOCAL_PACK_ENTRYPOINT} pid={pid}"); + Ok(pid) +} + +fn local_pack_entrypoint_request(name: &str) -> GuestRequest { + GuestRequest::RunDetached { + argv: vec![LOCAL_PACK_ENTRYPOINT.to_string()], + env: mvm_hostd::workload_env::workload_egress_env(name), + } +} + /// The processes the guest agent is tracking. pub fn list_processes(name: &str) -> Result> { emit_vsock_rpc_audit(name, &GuestRequest::ProcList); @@ -498,6 +527,28 @@ mod tests { use std::cell::Cell; use std::io::{Error, ErrorKind}; + #[test] + fn named_pack_dispatch_uses_only_the_sealed_entrypoint_and_mediated_egress() { + let home = tempfile::tempdir().expect("isolated home"); + let mut env = mvm_core::util::test_env::TestEnv::new(); + env.isolate_mvm_home(home.path()); + let path = mvm_core::config::vm_substitution_env_path("packed"); + std::fs::create_dir_all(path.parent().expect("substitution directory")) + .expect("create state"); + std::fs::write(&path, br#"[["API_KEY","mvm-secret://opaque-placeholder"]]"#) + .expect("placeholder, never a plaintext secret"); + + let GuestRequest::RunDetached { argv, env } = local_pack_entrypoint_request("packed") + else { + panic!("named pack must dispatch the detached sealed entrypoint"); + }; + assert_eq!(argv, ["/etc/mvm/entrypoint"]); + assert!(env.iter().any(|(key, _)| key == "HTTPS_PROXY")); + assert!(env.iter().any(|(key, value)| { + key == "API_KEY" && value == "mvm-secret://opaque-placeholder" + })); + } + fn handshake_hangup() -> anyhow::Error { anyhow::Error::new(SessionError::Io(Error::from(ErrorKind::UnexpectedEof))) .context("host session handshake failed") diff --git a/crates/mvm-client/src/inventory.rs b/crates/mvm-client/src/inventory.rs index 27a7e727e6..d95631c4a9 100644 --- a/crates/mvm-client/src/inventory.rs +++ b/crates/mvm-client/src/inventory.rs @@ -424,6 +424,7 @@ mod tests { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-client/src/launch/bundle_runtime.rs b/crates/mvm-client/src/launch/bundle_runtime.rs index ca03c0309c..ff098051b2 100644 --- a/crates/mvm-client/src/launch/bundle_runtime.rs +++ b/crates/mvm-client/src/launch/bundle_runtime.rs @@ -53,16 +53,46 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< return Ok(false); }; let registry = BundleRegistry::default_path()?; + attach_bundle_from_registry(config, backend, ®istry, &archive, None) +} + +/// Attach a held composition without consulting or modifying the installed registry. +pub(crate) fn attach_transient_bundle( + config: &mut VmStartConfig, + backend: &str, + source: &super::transient_composition::TransientBoot<'_>, +) -> Result<()> { + source.reverify()?; + ensure!( + Path::new(&config.rootfs_path) == source.rootfs()? + && config.kernel_path.as_deref().map(Path::new) == Some(source.kernel()?.as_path()), + "transient boot differs from its private signed files" + ); + let registry = BundleRegistry::new(source.registry_root().join("bundles")); + ensure!( + attach_bundle_from_registry(config, backend, ®istry, source.archive(), Some(source))?, + "transient composition has no signed boot closure" + ); + source.reverify() +} + +fn attach_bundle_from_registry( + config: &mut VmStartConfig, + backend: &str, + registry: &BundleRegistry, + archive: &Path, + transient: Option<&super::transient_composition::TransientBoot<'_>>, +) -> Result { let release = mvm_core::registry_pack::application_bundle::trust_for_installed_archive( FsTrustStore::default_path()?, - &archive, + archive, ); let (verified, trust): (VerifiedBundleFile, Box) = if let Some(host_key) = crate::bundle::existing_host_verifying_key()? { let host_key_id = key_id_from_pubkey(&host_key); let trust = LocalBundleTrustStore::new(release, host_key_id.clone(), host_key); let verified = - verify_bundle_file(&archive, &trust).context("verifying bundle boot assets")?; + verify_bundle_file(archive, &trust).context("verifying bundle boot assets")?; ensure!( bundle_origin(&verified.manifest, &host_key_id, trust.release()) != BundleOrigin::Untrusted, @@ -71,7 +101,7 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< (verified, Box::new(trust)) } else { let verified = - verify_bundle_file(&archive, &release).context("verifying bundle boot assets")?; + verify_bundle_file(archive, &release).context("verifying bundle boot assets")?; ensure!( verified .manifest @@ -82,10 +112,24 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< ); (verified, Box::new(release)) }; - mvm_core::plan::bundle::require_composable_local_enrollment( - &verified.manifest, - &FsTrustStore::default_path()?, - )?; + if let Some(source) = transient { + ensure!( + verified.bundle_sha256 == source.sha256() + && source.manifest() == &verified.manifest + && verified + .manifest + .labels + .get(BUNDLE_ORIGIN_LABEL) + .is_some_and(|label| label == LOCAL_BUILD_ORIGIN), + "transient composition differs from this host's signed local bundle" + ); + source.reverify()?; + } else { + mvm_core::plan::bundle::require_composable_local_enrollment( + &verified.manifest, + &FsTrustStore::default_path()?, + )?; + } let canonical = verified.manifest.members.iter().any(|member| { matches!( member, @@ -137,6 +181,31 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< "bundle boot-assets architecture differs from host" ); let kind = BackendKind::from_label(backend).context("unknown bundle boot backend")?; + if let Some(proof) = &boot.local_development_base { + ensure!( + (cfg!(unix) && kind == BackendKind::Hvf) + || (cfg!(target_os = "linux") && kind == BackendKind::Firecracker), + "local development kernel requires native descriptor custody on this backend" + ); + ensure!( + config.canonical_kernel.is_none(), + "local development base cannot borrow canonical kernel authority" + ); + mvm_core::plan::bundle::verify_local_development_source(proof) + .context("local base composition source changed before launch")?; + let kernel = verified + .manifest + .find_by_role(&ArtifactRole::Kernel) + .context("local base kernel missing")?; + ensure!( + config + .kernel_read_sha256 + .as_deref() + .is_none_or(|digest| digest == kernel.sha256), + "local base conflicts with another kernel descriptor pin" + ); + config.kernel_read_sha256 = Some(kernel.sha256.clone()); + } // A canonical bundle retains a kernel generation only where the // backend can prove that generation's exit; refuse the rest before // paying for image selection or runtime assembly, not after. @@ -152,10 +221,15 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< &support, &mvm_build::stage0_kernel::current_image_set_protocol_support(), )?; - attach_cli_runtime(config, &installed, runtime)?; + attach_cli_runtime( + config, + &installed, + runtime, + boot.local_development_base.is_some(), + )?; if needs_owner { let owner = std::sync::Arc::new(mvm_core::canonical_linux::prepare_canonical_linux( - &archive, + archive, &verified.plan_artifact(), kind, mvm_build::stage0_kernel::current_image_set_protocol_support(), @@ -173,7 +247,7 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< "conflicting portable runtime evidence" ); let proof = mvm_core::vm_backend::LegacyPortableRuntimeProof { - archive: archive.clone(), + archive: archive.to_path_buf(), pin: verified.plan_artifact(), manifest_sha256: boot.image_set.manifest_sha256.as_str().to_owned(), arch: boot.arch, @@ -284,6 +358,7 @@ fn attach_cli_runtime( config: &mut VmStartConfig, installed: &mvm_core::plan::bundle::InstalledBundle, runtime: &mvm_core::plan::bundle::EmbeddedCliBootAssets, + local_development_base: bool, ) -> Result<()> { use mvm_build::guest_runtime::evidence::{ RuntimeArtifactSelection, verify_runtime_release_proof, @@ -313,7 +388,49 @@ fn attach_cli_runtime( version: runtime.version.clone(), arch: GuestArch::host(), }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = if let Some(pull_request) = runtime.pr_test_pull_request { + ensure!( + pull_request > 0 + && local_development_base + && installed.manifest.schema_version + >= mvm_contract::plan::bundle::PR_TEST_RUNTIME_MIN_SCHEMA + && installed.manifest.profile.as_deref() == Some("dev") + && installed + .manifest + .labels + .get(mvm_core::plan::bundle::BUNDLE_ORIGIN_LABEL) + .is_some_and(|origin| origin == mvm_core::plan::bundle::LOCAL_BUILD_ORIGIN), + "PR test runtime requires a host-signed local development bundle" + ); + Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: proof.clone(), + pull_request, + }) + } else { + None + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + ensure!( + runtime.pr_test_pull_request.is_none(), + "PR test runtime requires an explicitly compiled test-only binary" + ); + #[cfg(not(feature = "pr-test-guest-runtime"))] + let _ = local_development_base; + #[cfg(feature = "pr-test-guest-runtime")] + ensure!( + config + .pr_test_runtime_proof + .as_ref() + .is_none_or(|existing| Some(existing) == pr_test.as_ref()), + "PR test runtime selection conflicts with the signed bundle" + ); if let Some(existing) = &config.runtime_release_proof { + #[cfg(feature = "pr-test-guest-runtime")] + ensure!( + pr_test.is_none(), + "PR test runtime cannot impersonate a release selection" + ); ensure!( existing == &proof, "CLI portable runtime proof conflicts with the signed bundle" @@ -333,6 +450,17 @@ fn attach_cli_runtime( let overlay_path = artifact(&runtime.overlay_artifact)?; let overlay_verity_path = artifact(&runtime.overlay_verity_artifact)?; let initramfs_path = artifact(&runtime.initramfs_artifact)?; + #[cfg(feature = "pr-test-guest-runtime")] + let verified = match &pr_test { + Some(pr_test) => mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + pr_test, + &runtime.version, + proof.arch, + ), + None => verify_runtime_release_proof(&proof, &runtime.version, proof.arch), + } + .context("verifying retained portable CLI runtime proof")?; + #[cfg(not(feature = "pr-test-guest-runtime"))] let verified = verify_runtime_release_proof(&proof, &runtime.version, proof.arch) .context("verifying retained portable CLI runtime release proof")?; let declaration = verified.declaration(proof.arch)?; @@ -351,8 +479,15 @@ fn attach_cli_runtime( minimum_protocol: mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, host_protocol: mvm_runtime::runtime_evidence::GUEST_RPC_PROTOCOL_VERSION, })?; + #[cfg(feature = "pr-test-guest-runtime")] + let provenance = match pr_test { + Some(proof) => RuntimeBindingProvenance::PrTest { proof }, + None => RuntimeBindingProvenance::Release { proof }, + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let provenance = RuntimeBindingProvenance::Release { proof }; RuntimeLaunchBinding::CliAssembly(CliRuntimeLaunchBinding { - provenance: RuntimeBindingProvenance::Release { proof }, + provenance, overlay_path, overlay_verity_path, initramfs_path, @@ -634,6 +769,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let artifacts = [ "runtime.tar.gz", @@ -681,13 +817,29 @@ mod tests { ..Default::default() }; let before = format!("{config:?}"); - let error = attach_cli_runtime(&mut config, &installed, &runtime).unwrap_err(); + let error = attach_cli_runtime(&mut config, &installed, &runtime, false).unwrap_err(); + assert!( + format!("{error:#}").contains(if cfg!(feature = "pr-test-guest-runtime") { + "verifying retained portable CLI runtime proof" + } else { + "verifying retained portable CLI runtime release proof" + }) + ); + assert_eq!(format!("{config:?}"), before); + let mut pr_runtime = runtime.clone(); + pr_runtime.pr_test_pull_request = Some(42); + let error = attach_cli_runtime(&mut config, &installed, &pr_runtime, false).unwrap_err(); assert!( - format!("{error:#}").contains("verifying retained portable CLI runtime release proof") + format!("{error:#}").contains(if cfg!(feature = "pr-test-guest-runtime") { + "host-signed local development bundle" + } else { + "explicitly compiled test-only binary" + }), + "test provenance cannot be used as a release: {error:#}" ); assert_eq!(format!("{config:?}"), before); std::fs::write(home.path().join("overlay.ext4"), b"changed").unwrap(); - let error = attach_cli_runtime(&mut config, &installed, &runtime).unwrap_err(); + let error = attach_cli_runtime(&mut config, &installed, &runtime, false).unwrap_err(); assert!(format!("{error:#}").contains("digest") || format!("{error:#}").contains("size")); assert_eq!(format!("{config:?}"), before); } @@ -1296,6 +1448,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let mut payload: Vec<(String, Vec)> = vec![ ("kernel".into(), kernel), diff --git a/crates/mvm-client/src/launch/detached.rs b/crates/mvm-client/src/launch/detached.rs index 3e1385fd32..8ef796517f 100644 --- a/crates/mvm-client/src/launch/detached.rs +++ b/crates/mvm-client/src/launch/detached.rs @@ -319,6 +319,7 @@ mod tests { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), diff --git a/crates/mvm-client/src/launch/machine_start.rs b/crates/mvm-client/src/launch/machine_start.rs index 70b34b110e..d6436b666a 100644 --- a/crates/mvm-client/src/launch/machine_start.rs +++ b/crates/mvm-client/src/launch/machine_start.rs @@ -88,6 +88,17 @@ pub struct MachineStart { pub admitted: mvm_hostd::plan_admission::AdmittedPlan, } +/// Clear stopped runtime state before staging a named pack into that same state directory. +/// +/// # Errors +/// Refuses an invalid name or backend, or state still owned by an unresolved VM. +pub fn prepare_named_pack_start(name: &str, hypervisor: &str) -> Result<()> { + mvm_core::naming::validate_vm_name(name)?; + AnyBackend::require_hypervisor_selectable(hypervisor)?; + super::discard_stopped_runtime_state(hypervisor, name)?; + Ok(()) +} + /// A validated local deployment: its directory, the rootfs it boots, and that /// rootfs's recorded digest. #[derive(Debug, Clone, PartialEq, Eq)] @@ -239,13 +250,79 @@ pub fn start_machine_spec( host: &dyn StartHost, params: MachineStartParams<'_>, ) -> Result { + start_machine_spec_from(spec, host, params, None) +} + +/// Start a named Dev composition under a new one-use admission, not an installed bundle. +/// +/// # Errors +/// Refuses changed recipe or archive pins, a different machine, or failed admission/boot. +pub fn start_local_pack_machine<'a>( + spec: &'a mp::MachineSpec, + source: &'a super::transient_composition::TransientBoot<'a>, + host: &dyn StartHost, + params: MachineStartParams<'_>, +) -> Result { + start_machine_spec_from(spec, host, params, Some(source)) +} + +#[tracing::instrument(level = "trace", skip_all)] +fn start_machine_spec_from<'a>( + spec: &'a mp::MachineSpec, + host: &dyn StartHost, + params: MachineStartParams<'_>, + source: Option<&'a super::transient_composition::TransientBoot<'a>>, +) -> Result { + if let Some(source) = source { + let pin = spec + .local_pack + .as_ref() + .context("named composition has no recorded source")?; + anyhow::ensure!( + spec.profile == "dev" + && spec.image.is_none() + && spec.manifest.is_none() + && spec.deployment.is_none() + && !spec.runtime_pack + && spec.registry_pack_image.is_none() + && !params.has_ad_hoc_argv + && params.mailbox.is_none(), + "local pack requires an unambiguous Dev machine source and no other launch authority" + ); + pin.verify_recipe()?; + source.reverify()?; + anyhow::ensure!( + source.sha256() == pin.archive_sha256.as_str() + && source + .registry_root() + .starts_with(mvm_core::config::vm_state_dir(&spec.name)), + "named composition differs from its recorded archive or machine" + ); + } else { + validate_registry_pack_source(spec)?; + } AnyBackend::require_hypervisor_selectable(params.hypervisor)?; - super::discard_stopped_runtime_state(params.hypervisor, &spec.name)?; - validate_registry_pack_source(spec)?; + if source.is_none() { + super::discard_stopped_runtime_state(params.hypervisor, &spec.name)?; + } let network_policy = persisted_network_policy(spec)?; let (memory_mib, mem_initial_mib) = mp::validate_machine_memory(&spec.memory, spec.mem_initial.as_deref())?; - let boot = if let Some(pin) = spec + let boot = if let Some(source) = source { + BootSource { + kernel: Some(source.kernel()?.to_string_lossy().into_owned()), + label: format!("local-pack:{}", source.sha256()), + rootfs: source.rootfs()?, + digest: format!( + "sha256:{}", + source + .manifest() + .find_by_role(&mvm_core::plan::bundle::ArtifactRole::Rootfs) + .context("named composition has no signed rootfs")? + .sha256 + ), + } + } else if let Some(pin) = spec .registry_pack_image .as_ref() .filter(|_| spec.manifest.is_none()) @@ -290,6 +367,7 @@ pub fn start_machine_spec( agent_verb: spec.agent_verb.clone(), caller_commitment: spec.caller_commitment.clone(), registry_pack_image: spec.registry_pack_image.clone(), + transient: source, has_ad_hoc_argv: params.has_ad_hoc_argv, grants: spec.grants.clone(), mailbox: params.mailbox, @@ -306,6 +384,10 @@ pub fn start_machine_spec( } pub(super) fn validate_registry_pack_source(spec: &mp::MachineSpec) -> Result<()> { + anyhow::ensure!( + spec.local_pack.is_none(), + "local pack machine requires a freshly admitted composition start" + ); if let Some(pin) = &spec.registry_pack_image { anyhow::ensure!( spec.image.is_none() && spec.deployment.is_none() && !spec.runtime_pack, @@ -488,6 +570,7 @@ mod tests { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: Vec::new(), peer: Vec::new(), @@ -574,6 +657,110 @@ mod tests { assert!(validate_registry_pack_source(&machine).is_err()); } + #[test] + fn a_local_recipe_cannot_fall_back_to_the_image_or_installed_bundle_path() { + let mut machine = spec("local-pack"); + machine.image = Some("alpine:latest".into()); + machine.local_pack = Some(mp::LocalPackSource { + recipe: std::path::PathBuf::from("/project/pack.toml"), + recipe_sha256: mvm_core::packs::Sha256Hex::from_bytes(b"recipe"), + archive_sha256: mvm_core::packs::Sha256Hex::from_bytes(b"archive"), + base: None, + }); + let error = validate_registry_pack_source(&machine) + .expect_err("a local recipe needs its own freshly admitted start path"); + assert!(error.to_string().contains("local pack"), "{error:#}"); + } + + #[test] + fn named_local_start_refuses_wrong_archive_before_asking_the_host_to_boot() { + let home = tempfile::tempdir().expect("mvm home"); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let (archive, _) = + crate::launch::transient_composition::tests::signed_local_composition(home.path()); + let mut staged = + crate::launch::transient_composition::TransientComposition::stage_for_machine( + &archive, "packed", + ) + .expect("private named source"); + let source = staged.consume().expect("one-use source"); + let recipe = home.path().join("pack.toml"); + std::fs::write(&recipe, b"recipe").expect("recipe"); + let mut machine = spec("packed"); + machine.profile = "dev".into(); + machine.local_pack = Some( + mp::LocalPackSource::from_prepared( + &recipe, + mvm_core::packs::Sha256Hex::from_bytes(b"another archive"), + ) + .expect("recorded source"), + ); + let host = image_host(); + let params = MachineStartParams { + hypervisor: "firecracker", + has_ad_hoc_argv: false, + mailbox: None, + }; + let error = start_local_pack_machine(&machine, &source, &host, params) + .expect_err("a changed cache entry never boots"); + assert!(error.to_string().contains("recorded archive"), "{error:#}"); + assert!(host.asked.borrow().is_empty(), "no OCI fallback"); + } + + #[cfg(feature = "test-support")] + #[test] + fn named_local_start_refuses_a_mock_without_native_kernel_custody() { + let home = tempfile::tempdir().expect("mvm home"); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let (archive, _) = + crate::launch::transient_composition::tests::signed_local_composition(home.path()); + let recipe = home.path().join("pack.toml"); + std::fs::write(&recipe, b"recipe").expect("recipe"); + prepare_named_pack_start("packed", "mock").expect("clear old state"); + let mut staged = + crate::launch::transient_composition::TransientComposition::stage_for_machine( + &archive, "packed", + ) + .expect("private source"); + let source = staged.consume().expect("one-use source"); + let mut machine = spec("packed"); + machine.profile = "dev".into(); + machine.local_pack = Some( + mp::LocalPackSource::from_prepared( + &recipe, + mvm_core::packs::Sha256Hex::new(source.sha256()).expect("archive hash"), + ) + .expect("source pin"), + ); + let backend = AnyBackend::Mock(mvm_runtime::MockBackend::new()); + let host = EmbedderStartHost::new(None, "dev").with_backend(&backend); + let error = start_local_pack_machine( + &machine, + &source, + &host, + MachineStartParams { + hypervisor: "mock", + has_ad_hoc_argv: false, + mailbox: None, + }, + ) + .expect_err("the mock cannot custody a local development kernel"); + assert!( + error + .to_string() + .contains("requires native descriptor custody"), + "{error:#}" + ); + assert!( + !mvm_core::plan::BundleRegistry::default_path() + .unwrap() + .root() + .exists() + ); + } + #[test] fn a_persisted_route_is_reapplied_to_the_start_policy() { use mvm_contract::policy::routes::{EgressRoute, EndpointRule, RouteOutcome}; diff --git a/crates/mvm-client/src/launch/mod.rs b/crates/mvm-client/src/launch/mod.rs index b91b975be5..18900950e7 100644 --- a/crates/mvm-client/src/launch/mod.rs +++ b/crates/mvm-client/src/launch/mod.rs @@ -30,6 +30,7 @@ pub mod startup_budget; #[cfg(test)] mod tests; mod transient; +pub mod transient_composition; mod unbooted; use unbooted::DefinitionOrigin; @@ -1187,6 +1188,9 @@ fn ensure_spec_bootable_in_process(spec: &mp::MachineSpec) -> Result<()> { if spec.runtime_pack { return Err(unsupported("a runtime-pack source")); } + if spec.local_pack.is_some() { + return Err(unsupported("a local pack source")); + } if !spec.volumes.is_empty() { return Err(unsupported( "CLI-grammar volume strings (use typed attachments)", diff --git a/crates/mvm-client/src/launch/persistent.rs b/crates/mvm-client/src/launch/persistent.rs index 33ddb535bd..d4f707817e 100644 --- a/crates/mvm-client/src/launch/persistent.rs +++ b/crates/mvm-client/src/launch/persistent.rs @@ -80,6 +80,8 @@ pub struct PersistentImageStartParams<'a> { pub caller_commitment: Option, /// Publisher-verified image pack to bind on every start, if selected. pub registry_pack_image: Option, + /// One-use private Dev composition, never a stored bundle reference. + pub transient: Option<&'a super::transient_composition::TransientBoot<'a>>, /// True when the caller will run a trailing `-- argv` command after boot /// (i.e. the machine is booted only to exec an ad-hoc command). An ad-hoc /// command issues the DevOnly `Exec` verb, so the admitted plan must NOT @@ -181,9 +183,11 @@ fn register_vm_name(vm_name: &str, network_name: &str) { /// Admit and start a persistent machine. Returns the admitted plan the /// machine booted under. +#[tracing::instrument(level = "trace", skip_all)] pub fn start_persistent_oci_machine( params: PersistentImageStartParams<'_>, ) -> Result { + let transient = params.transient; let PreparedPersistentBoot { backend, start_config, @@ -202,6 +206,7 @@ pub fn start_persistent_oci_machine( prepared_volumes, name, backend_name, + transient, ) } @@ -213,6 +218,7 @@ struct PreparedPersistentBoot { built_runtime: Option, } +#[tracing::instrument(level = "trace", skip_all)] fn prepare_persistent_boot( params: PersistentImageStartParams<'_>, ) -> Result { @@ -233,6 +239,7 @@ fn prepare_persistent_boot( agent_verb, caller_commitment, registry_pack_image, + transient, has_ad_hoc_argv, grants, mailbox, @@ -274,8 +281,18 @@ fn prepare_persistent_boot( register_vm_name(name, "default"); let (verity_path, roothash) = mvm_runtime::microvm::probe_verity_sidecar(&rootfs_path.to_string_lossy()); - let initrd_path = persistent_oci_effective_initrd(rootfs_path)?; - let bundle_archive = super::bundle_runtime::installed_archive_for_rootfs(rootfs_path)?; + let initrd_path = if let Some(source) = transient { + source + .initrd() + .map(|path| path.to_string_lossy().into_owned()) + } else { + persistent_oci_effective_initrd(rootfs_path)? + }; + let bundle_archive = if let Some(source) = transient { + Some(source.archive().to_path_buf()) + } else { + super::bundle_runtime::installed_archive_for_rootfs(rootfs_path)? + }; let mut start_config = VmStartParams::builder() .name(name.to_string()) @@ -313,8 +330,12 @@ fn prepare_persistent_boot( let admission = crate::launch::boot_order::admit_after_preparation( &mut start_config, |config| { - attach_runtime_overlay_if_cached(config, backend_name)?; - attach_universal_initramfs_if_cached(config, backend_name) + if let Some(source) = transient { + super::bundle_runtime::attach_transient_bundle(config, backend_name, source) + } else { + attach_runtime_overlay_if_cached(config, backend_name)?; + attach_universal_initramfs_if_cached(config, backend_name) + } }, |config| { let admission_ledger = InMemoryNonceLedger::new(); @@ -361,9 +382,14 @@ fn prepare_persistent_boot( keys_dir: None, audit_dir: None, policy_dir: None, - bundle_pin: bundle_archive - .as_deref() - .map(crate::admission::BundlePin::boots), + bundle_pin: transient.map_or_else( + || { + bundle_archive + .as_deref() + .map(crate::admission::BundlePin::boots) + }, + |source| Some(crate::admission::BundlePin::transient(source)), + ), bundle_posture: None, deps_volume: None, shares: shares_from_volume_cfg(volumes), @@ -445,6 +471,11 @@ fn prepare_persistent_boot( emit_failed(&admission, "registry-pack-runtime", error); })?; } + if let Some(source) = transient { + source.reverify().inspect_err(|error| { + emit_failed(&admission, "transient-source", error); + })?; + } Ok(PreparedPersistentBoot { backend, start_config, @@ -480,6 +511,7 @@ fn complete_writable_images( Ok(()) } +#[tracing::instrument(level = "trace", skip_all)] fn finish_legacy_persistent( backend: mvm_runtime::backend::AnyBackend, start_config: mvm_core::vm_backend::VmStartConfig, @@ -487,7 +519,13 @@ fn finish_legacy_persistent( mut prepared_volumes: crate::volume::LaunchPreparation, name: &str, backend_name: &str, + transient: Option<&super::transient_composition::TransientBoot<'_>>, ) -> Result { + if let Some(source) = transient { + source.reverify().inspect_err(|error| { + emit_failed(&admission, "transient-source", error); + })?; + } // VMM selection + workload-support check + start move behind the facade; the // admission gate (above) and the launched/failed emits stay here. #[cfg(unix)] diff --git a/crates/mvm-client/src/launch/request.rs b/crates/mvm-client/src/launch/request.rs index e2556bf9af..5cc85c5b3f 100644 --- a/crates/mvm-client/src/launch/request.rs +++ b/crates/mvm-client/src/launch/request.rs @@ -217,6 +217,7 @@ pub(crate) fn persisted_spec_from_request( LaunchSource::RegistryPack { pin } => Some(pin.clone()), _ => None, }, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-client/src/launch/runtime_source.rs b/crates/mvm-client/src/launch/runtime_source.rs index 4cf98a249e..9e7a66189d 100644 --- a/crates/mvm-client/src/launch/runtime_source.rs +++ b/crates/mvm-client/src/launch/runtime_source.rs @@ -154,6 +154,16 @@ fn attach_runtime_overlay_selection( if start_config.legacy_portable_runtime.is_some() || start_config.legacy_bundle_runtime.is_some() || start_config.runtime_release_proof.is_some() + || { + #[cfg(feature = "pr-test-guest-runtime")] + { + start_config.pr_test_runtime_proof.is_some() + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + false + } + } || start_config.source_runtime_archive_sha256.is_some() || start_config.unsigned_runtime_development { diff --git a/crates/mvm-client/src/launch/tests.rs b/crates/mvm-client/src/launch/tests.rs index 11aac3e6db..e5547f3859 100644 --- a/crates/mvm-client/src/launch/tests.rs +++ b/crates/mvm-client/src/launch/tests.rs @@ -1001,6 +1001,7 @@ async fn start_refuses_spec_shapes_the_in_process_backend_cannot_honor() { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-client/src/launch/transient_composition.rs b/crates/mvm-client/src/launch/transient_composition.rs new file mode 100644 index 0000000000..15bab01307 --- /dev/null +++ b/crates/mvm-client/src/launch/transient_composition.rs @@ -0,0 +1,641 @@ +//! Invocation-private custody of a verified local composition. +//! +//! This directory is not the installed bundle registry. It exists only while +//! the caller retains this value, and does not enroll a publisher key. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, ensure}; +use mvm_core::plan::bundle::{ + ArtifactRole, BundleMember, BundleOrigin, BundleRegistry, FsTrustStore, InstalledBundle, + LocalBundleTrustStore, bundle_origin, key_id_from_pubkey, verify_bundle_file, +}; + +/// Exact signed bytes staged for one Dev boot, without installing a bundle. +#[derive(Debug)] +pub struct TransientComposition { + directory: Option, + machine_name: Option, + installed: InstalledBundle, + archive: PathBuf, + archive_sha256: String, + host_key_id: mvm_core::plan::KeyId, + created: Instant, + process_id: u32, + consumed: bool, +} + +impl TransientComposition { + /// Verify a host-signed composition and materialize it in a private directory. + /// + /// # Errors + /// Refuses changed, foreign-signed, non-composed or non-regular input. + pub fn stage(source: &Path) -> Result { + Self::stage_with(source, None, || { + tempfile::tempdir().context("creating private composition staging") + }) + } + + /// Stage a one-use boot under the named machine's instance lifecycle. + /// + /// # Errors + /// Refuses an unsafe machine name, untrusted archive or non-private state. + pub fn stage_for_machine(source: &Path, name: &str) -> Result { + mvm_core::naming::validate_vm_name(name)?; + Self::stage_with(source, Some(name), || { + let parent = mvm_core::config::vm_state_dir(name).join("local-pack"); + mvm_core::config::create_private_dir(&parent)?; + tempfile::Builder::new() + .prefix("boot-") + .tempdir_in(parent) + .context("creating named composition staging") + }) + } + + #[tracing::instrument(level = "trace", skip_all)] + fn stage_with( + source: &Path, + machine_name: Option<&str>, + make_directory: impl FnOnce() -> Result, + ) -> Result { + let metadata = std::fs::symlink_metadata(source)?; + ensure!( + metadata.file_type().is_file(), + "composition source is not a regular file" + ); + mvm_core::private_fs::require_current_owner(&metadata)?; + let host = mvm_hostd::audit::host_keypair::load_or_init()?; + let host_key_id = key_id_from_pubkey(&host.verifying); + let release = mvm_core::registry_pack::application_bundle::trust_for_installed_archive( + FsTrustStore::default_path()?, + source, + ); + let trust = LocalBundleTrustStore::new(release, host_key_id.clone(), host.verifying); + let source_verified = verify_bundle_file(source, &trust) + .context("verifying the local composition before private staging")?; + ensure!( + bundle_origin(&source_verified.manifest, &host_key_id, trust.release()) + == BundleOrigin::LocalBuild + && source_verified.manifest.schema_version >= 8 + && source_verified + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalComposition(_))), + "transient boot requires this host's signed local composition" + ); + + let directory = make_directory()?; + let registry = BundleRegistry::new(directory.path().join("bundles")); + let installed = registry + .install_file(source, &trust, false) + .context("materializing verified composition privately")?; + ensure!( + installed.sha256 == source_verified.bundle_sha256 + && installed.manifest == source_verified.manifest, + "composition changed during private staging" + ); + let archive = registry.archive_path(&installed.sha256); + let staged = Self { + directory: Some(directory), + machine_name: machine_name.map(str::to_owned), + archive, + archive_sha256: installed.sha256.clone(), + installed, + host_key_id, + created: Instant::now(), + process_id: std::process::id(), + consumed: false, + }; + staged.reverify()?; + Ok(staged) + } + + /// Consume the staged source once, after rechecking the original signed bytes. + /// + /// # Errors + /// Refuses expiry, replay, changed artifacts or a different process. + #[tracing::instrument(level = "trace", skip_all)] + pub fn consume(&mut self) -> Result> { + ensure!(!self.consumed, "transient composition already consumed"); + ensure!( + self.process_id == std::process::id() + && self.created.elapsed() < Duration::from_secs(120), + "transient composition expired or changed processes" + ); + self.reverify()?; + self.consumed = true; + Ok(TransientBoot { + staged: self, + admitted: Cell::new(false), + }) + } + + /// Keep private evidence when an admitted VM may still need recovery. + pub fn retain_on_error(&mut self) -> Option { + self.directory.take().map(tempfile::TempDir::keep) + } + + /// Keep a verified named boot's bytes until its VM is confirmed stopped. + /// + /// # Errors + /// Refuses an unconsumed, changed or invocation-private source. + #[tracing::instrument(level = "trace", skip_all)] + pub fn retain_for_running_machine(&mut self) -> Result { + let name = self + .machine_name + .as_deref() + .context("composition was not staged for a named machine")?; + ensure!(self.consumed, "named composition was not consumed"); + self.reverify()?; + let directory = self + .directory + .as_ref() + .context("composition already released")?; + ensure!( + directory + .path() + .starts_with(mvm_core::config::vm_state_dir(name)), + "named composition escaped its machine state directory" + ); + Ok(self + .directory + .take() + .context("composition already released")? + .keep()) + } + + fn reverify(&self) -> Result<()> { + let directory = self + .directory + .as_ref() + .context("transient composition custody was already released")?; + ensure!( + self.process_id == std::process::id(), + "transient composition moved to another process" + ); + let metadata = std::fs::symlink_metadata(directory.path())?; + ensure!( + metadata.file_type().is_dir(), + "transient composition directory changed type" + ); + mvm_core::private_fs::require_current_owner(&metadata)?; + ensure!( + self.installed.root.starts_with(directory.path()), + "transient composition escaped its private directory" + ); + let host = mvm_hostd::audit::host_keypair::load_or_init()?; + let host_key_id = key_id_from_pubkey(&host.verifying); + ensure!( + host_key_id == self.host_key_id, + "transient host signer changed" + ); + let release = mvm_core::registry_pack::application_bundle::trust_for_installed_archive( + FsTrustStore::default_path()?, + &self.archive, + ); + let trust = LocalBundleTrustStore::new(release, self.host_key_id.clone(), host.verifying); + let verified = verify_bundle_file(&self.archive, &trust)?; + ensure!( + verified.bundle_sha256 == self.archive_sha256 + && verified.manifest == self.installed.manifest + && bundle_origin(&verified.manifest, &self.host_key_id, trust.release()) + == BundleOrigin::LocalBuild, + "transient archive changed" + ); + let registry = BundleRegistry::new(directory.path().join("bundles")); + ensure!( + registry + .verified_manifest(&self.archive_sha256, &trust)? + .as_ref() + == Some(&self.installed.manifest), + "transient materialized manifest differs from the archive" + ); + for artifact in &verified.manifest.artifacts { + let path = self.installed.root.join(&artifact.path); + ensure!( + std::fs::symlink_metadata(&path)?.file_type().is_file() + && std::fs::metadata(&path)?.len() == artifact.size_bytes + && mvm_core::crypto::image_verify::sha256_file(&path)? == artifact.sha256, + "transient boot artifact differs from the signed archive" + ); + } + Ok(()) + } +} + +/// A one-use, process-local Dev boot source. No installed-bundle address exists. +#[derive(Debug)] +pub struct TransientBoot<'a> { + staged: &'a TransientComposition, + admitted: Cell, +} + +impl TransientBoot<'_> { + /// Spend the sole admission attempt for this invocation. + /// + /// # Errors + /// Refuses replay or a token used after its process-local window. + pub(crate) fn admit_once(&self) -> Result<()> { + ensure!( + self.staged.process_id == std::process::id() + && self.staged.created.elapsed() < Duration::from_secs(120), + "transient composition admission expired" + ); + ensure!( + !self.admitted.replace(true), + "transient composition was already admitted" + ); + Ok(()) + } + + /// Recheck the held archive and materialized files before handing them to boot. + /// + /// # Errors + /// Refuses a changed private archive or boot artifact. + pub fn reverify(&self) -> Result<()> { + self.staged.reverify() + } + + /// The verified private archive for this admission only. + pub fn archive(&self) -> &Path { + &self.staged.archive + } + + /// The signed manifest, held beside the private bytes. + pub fn manifest(&self) -> &mvm_core::plan::bundle::BundleManifest { + &self.staged.installed.manifest + } + + /// The content address of the exact signed archive. + pub fn sha256(&self) -> &str { + &self.staged.archive_sha256 + } + + /// The invocation-private materialization root. + pub(crate) fn registry_root(&self) -> &Path { + self.staged + .directory + .as_ref() + .expect("live transient boot retains private directory") + .path() + } + + /// Whether this invocation reached admitted-plan construction. + pub fn was_admitted(&self) -> bool { + self.admitted.get() + } + + /// The exact materialized kernel. + pub fn kernel(&self) -> Result { + self.staged + .installed + .path_for_role(&ArtifactRole::Kernel) + .context("composition has no signed kernel") + } + + /// The exact materialized root filesystem. + pub fn rootfs(&self) -> Result { + self.staged + .installed + .path_for_role(&ArtifactRole::Rootfs) + .context("composition has no signed rootfs") + } + + /// The signed initramfs, when the composition embeds one. + pub fn initrd(&self) -> Option { + self.staged.installed.path_for_role(&ArtifactRole::Initrd) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use mvm_contract::plan::bundle::{ + EmbeddedCliBootAssets, LocalComposition, LocalDevelopmentBaseProof, + }; + use mvm_core::arch::GuestArch; + use mvm_core::util::test_env::TestEnv; + + pub(crate) fn signed_local_composition(home: &Path) -> (PathBuf, Vec) { + use mvm_core::plan::bundle::{ + ArtifactRole, BundleArtifact, BundleManifest, VerityInfo, write_bundle, + }; + let signer = mvm_hostd::audit::host_keypair::load_or_init().unwrap(); + let arch = GuestArch::host().to_string(); + let kernel = b"kernel bytes"; + let tar = b"composition tar bytes"; + let tar_path = home.join("base.tar.gz"); + std::fs::write(&tar_path, tar).unwrap(); + let tar_path = std::fs::canonicalize(tar_path).unwrap(); + let mut linux = serde_json::to_value( + mvm_build::published_image_set::fixture::ImageSetFixture::complete().into_manifest(), + ) + .unwrap(); + linux["schema_version"] = 3.into(); + linux["set_version"] = "0.0.0-local".into(); + linux["producer"] = serde_json::json!({ + "local_checkouts": { + "images": { "commit": "a".repeat(40), "worktree": { "state": "clean" } } + } + }); + linux.as_object_mut().unwrap().remove("mvm_source_commit"); + linux.as_object_mut().unwrap().remove("revocation_channel"); + linux["compatibility"] + .as_object_mut() + .unwrap() + .remove("guest_agent_protocol"); + linux["compatibility"]["builder_boot_abi"] = 2.into(); + linux["compatibility"]["base_image_contract"] = 1.into(); + linux["members"] = serde_json::json!([ + { + "role": {"workload_kernel": "default_tenant"}, + "target": {"arch": arch}, + "boot_protocol": "linux_direct", + "artifacts": [{ + "name": "vmlinux", + "format": {"kernel": if arch == "x86_64" { "elf" } else { "image" }}, + "sha256": mvm_core::plan::bundle::sha256_hex(kernel), + "size": kernel.len() + }], + "required_capabilities": ["virtio_vsock"] + }, + { + "role": {"workload_rootfs": "default_tenant"}, + "target": {"arch": arch}, + "artifacts": [ + { + "name": "rootfs.ext4", + "format": "ext4", + "sha256": mvm_core::plan::bundle::sha256_hex(b"rootfs"), + "size": 6 + }, + { + "name": "rootfs.verity", + "format": "verity_hash_tree", + "sha256": mvm_core::plan::bundle::sha256_hex(b"verity"), + "size": 6 + }, + { + "name": "rootfs.roothash", + "format": "verity_root_hash", + "sha256": mvm_core::plan::bundle::sha256_hex("a".repeat(64).as_bytes()), + "size": 64 + }, + { + "name": "rootfs.tar.gz", + "format": "tar_gz", + "sha256": mvm_core::plan::bundle::sha256_hex(tar), + "size": tar.len() + } + ], + "required_capabilities": ["virtio_blk", "dm_verity"] + } + ]); + let linux = serde_json::to_vec(&linux).unwrap(); + let lock = serde_json::to_vec(&serde_json::json!({ + "schema": "mvm.composable-pack-lock/v1", + "spec_sha256": mvm_core::plan::bundle::sha256_hex(b"spec"), + "source_tree_sha256": mvm_core::plan::bundle::sha256_hex(b"source"), + "application_archive_sha256": mvm_core::plan::bundle::sha256_hex(b"app"), + "base_manifest_sha256": mvm_core::plan::bundle::sha256_hex(&linux), + "runtime_pins": [], + "target": if arch == "x86_64" { "x86_64-linux" } else { "aarch64-linux" }, + "profile": "default_tenant", + "producer_revision": "a".repeat(40), + "producer_version": "0.27.0" + })) + .unwrap(); + let boot = EmbeddedCliBootAssets { + manifest_artifact: "boot-image-set.json".into(), + version: "0.27.0".into(), + arch: arch.clone(), + archive_artifact: "guest-runtime.tar.gz".into(), + checksum_artifact: "guest-runtime.sha256".into(), + signature_bundle_artifact: "guest-runtime.bundle".into(), + checksum_signature_bundle_artifact: "guest-runtime.sha256.bundle".into(), + overlay_artifact: "runtime-overlay.ext4".into(), + overlay_verity_artifact: "runtime-overlay.verity".into(), + initramfs_artifact: "initrd".into(), + overlay_roothash: "a".repeat(64), + protocol_version: 2, + min_supported_protocol_version: 2, + pr_test_pull_request: None, + }; + let items: Vec<(&str, ArtifactRole, Vec)> = vec![ + ("vmlinux", ArtifactRole::Kernel, kernel.to_vec()), + ("rootfs.ext4", ArtifactRole::Rootfs, b"rootfs".to_vec()), + ( + "rootfs.verity", + ArtifactRole::VerityHashSidecar, + b"verity".to_vec(), + ), + ( + "rootfs.roothash", + ArtifactRole::Other, + "a".repeat(64).into_bytes(), + ), + ("boot-image-set.json", ArtifactRole::Other, linux), + ("composition-lock.json", ArtifactRole::Other, lock), + ( + "guest-runtime.tar.gz", + ArtifactRole::Other, + b"runtime".to_vec(), + ), + ( + "guest-runtime.sha256", + ArtifactRole::Other, + b"checksum".to_vec(), + ), + ( + "guest-runtime.bundle", + ArtifactRole::Other, + b"signature".to_vec(), + ), + ( + "guest-runtime.sha256.bundle", + ArtifactRole::Other, + b"signature".to_vec(), + ), + ( + "runtime-overlay.ext4", + ArtifactRole::Other, + b"overlay".to_vec(), + ), + ( + "runtime-overlay.verity", + ArtifactRole::Other, + b"verity".to_vec(), + ), + ("initrd", ArtifactRole::Initrd, b"initrd".to_vec()), + ]; + let mut manifest = BundleManifest { + schema_version: 9, + publisher: "local".into(), + key_id: key_id_from_pubkey(&signer.verifying), + arch, + kernel_version: None, + profile: Some("dev".into()), + workload_label: None, + created_at: "2026-11-11T00:00:00Z".into(), + labels: Default::default(), + artifacts: items + .iter() + .map(|(name, role, bytes)| BundleArtifact { + name: (*name).into(), + role: role.clone(), + path: format!("artifacts/{name}"), + sha256: mvm_core::plan::bundle::sha256_hex(bytes), + size_bytes: bytes.len() as u64, + }) + .collect(), + members: vec![ + BundleMember::EmbeddedCliBootAssets(boot), + BundleMember::LocalDevelopmentBaseProof(LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".into(), + composition_tar_sha256: mvm_core::plan::bundle::sha256_hex(tar), + composition_tar_size: tar.len() as u64, + composition_tar_path: tar_path.to_str().unwrap().into(), + }), + BundleMember::LocalComposition(LocalComposition { + lock_artifact: "composition-lock.json".into(), + layers: Vec::new(), + }), + ], + verity: Some(VerityInfo { + roothash: "a".repeat(64), + sidecar_artifact: "rootfs.verity".into(), + }), + resources: None, + }; + mvm_core::plan::bundle::mark_local_build(&mut manifest); + let bytes = write_bundle( + &manifest, + &signer.signing, + items + .into_iter() + .map(|(name, _, bytes)| (format!("artifacts/{name}"), bytes)) + .collect(), + ) + .unwrap(); + let path = home.join("composition.mvmpkg"); + std::fs::write(&path, &bytes).unwrap(); + (path, bytes) + } + + #[test] + fn unverified_source_never_creates_an_installed_bundle_entry() { + let home = tempfile::tempdir().unwrap(); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let source = home.path().join("composed.mvmpkg"); + std::fs::write(&source, b"unsigned source").unwrap(); + + assert!(TransientComposition::stage(&source).is_err()); + assert!(!BundleRegistry::default_path().unwrap().root().exists()); + } + + #[test] + fn admitted_transient_source_cannot_be_replayed_or_used_after_expiry() { + let directory = tempfile::tempdir().unwrap(); + let manifest = mvm_core::plan::bundle::BundleManifest { + schema_version: 8, + publisher: "test".into(), + key_id: mvm_core::plan::KeyId("0".repeat(32)), + arch: mvm_core::arch::GuestArch::host().to_string(), + kernel_version: None, + profile: None, + workload_label: None, + created_at: "2026-01-01T00:00:00Z".into(), + labels: Default::default(), + artifacts: Vec::new(), + members: Vec::new(), + verity: None, + resources: None, + }; + let staged = TransientComposition { + installed: InstalledBundle { + sha256: "0".repeat(64), + root: directory.path().join("bundles"), + manifest, + }, + archive: directory.path().join("unused"), + archive_sha256: "0".repeat(64), + host_key_id: mvm_core::plan::KeyId("0".repeat(32)), + created: Instant::now(), + process_id: std::process::id(), + consumed: true, + directory: Some(directory), + machine_name: None, + }; + let boot = TransientBoot { + staged: &staged, + admitted: Cell::new(false), + }; + boot.admit_once().unwrap(); + assert!(boot.admit_once().is_err()); + + let expired = TransientComposition { + created: Instant::now() - Duration::from_secs(121), + ..staged + }; + let boot = TransientBoot { + staged: &expired, + admitted: Cell::new(false), + }; + assert!(boot.admit_once().is_err()); + } + + #[test] + fn signed_composition_stages_privately_and_rechecks_selected_bytes() { + let home = tempfile::tempdir().unwrap(); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let (archive, _) = signed_local_composition(home.path()); + let mut staged = TransientComposition::stage(&archive).unwrap(); + assert!(!BundleRegistry::default_path().unwrap().root().exists()); + { + let source = staged.consume().unwrap(); + source.reverify().unwrap(); + assert_eq!(std::fs::read(source.rootfs().unwrap()).unwrap(), b"rootfs"); + assert!( + crate::launch::manifest_ref::resolve_manifest_arg(source.sha256()).is_err(), + "private composition has no reusable installed-bundle address" + ); + } + assert!(staged.consume().is_err()); + } + + #[test] + fn named_staging_is_owned_by_the_machine_until_its_confirmed_stop() { + let home = tempfile::tempdir().expect("mvm home"); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let (archive, _) = signed_local_composition(home.path()); + assert!(TransientComposition::stage_for_machine(&archive, "../escape").is_err()); + + let mut staged = TransientComposition::stage_for_machine(&archive, "packed") + .expect("private named staging"); + let rootfs = { + let boot = staged.consume().expect("one-use boot"); + boot.reverify().expect("held bytes"); + boot.rootfs().expect("rootfs") + }; + assert!( + rootfs.starts_with(mvm_core::config::vm_state_dir("packed")), + "named source must stay within the machine's owned state directory" + ); + let held = staged + .retain_for_running_machine() + .expect("retain exact bytes"); + drop(staged); + assert!(rootfs.is_file(), "boot bytes survive the CLI's preparation"); + assert!(held.starts_with(mvm_core::config::vm_state_dir("packed"))); + assert!(!BundleRegistry::default_path().unwrap().root().exists()); + crate::local::remove_stopped_runtime_state("packed") + .expect("confirmed stop removes retained bytes"); + assert!(!rootfs.exists()); + } +} diff --git a/crates/mvm-client/src/local.rs b/crates/mvm-client/src/local.rs index 0ed21f347f..a6e4908ec5 100644 --- a/crates/mvm-client/src/local.rs +++ b/crates/mvm-client/src/local.rs @@ -3114,6 +3114,7 @@ mod tests { runtime_pack: false, registry_pack_image: None, resolved_digest: None, + local_pack: None, net: false, allow_host: vec![], peer: vec![], diff --git a/crates/mvm-client/src/policy_profiles/mod.rs b/crates/mvm-client/src/policy_profiles/mod.rs index adaa9eb98c..19f443eda3 100644 --- a/crates/mvm-client/src/policy_profiles/mod.rs +++ b/crates/mvm-client/src/policy_profiles/mod.rs @@ -31,7 +31,10 @@ pub mod source; pub use manifest::{FoldedLaunch, LaunchFlags, ResolvedManifest, fold}; pub use merge::{LayerSummary, ResolvedPolicy}; pub use model::{GroupFile, PolicyBody, ProfileFile}; -pub use resolve::{MAX_EXTENDS_DEPTH, Platform, PolicySelection, ProjectPolicy, resolve}; +pub use resolve::{ + MAX_EXTENDS_DEPTH, Platform, PolicySelection, ProjectPolicy, VerifiedPolicyInputs, resolve, + resolve_verified_policies, resolve_verified_policy, +}; pub use source::{LayerOrigin, PolicyError, PolicyRef, PolicyStore}; #[cfg(test)] diff --git a/crates/mvm-client/src/policy_profiles/resolve.rs b/crates/mvm-client/src/policy_profiles/resolve.rs index 5031549e04..ca937a5800 100644 --- a/crates/mvm-client/src/policy_profiles/resolve.rs +++ b/crates/mvm-client/src/policy_profiles/resolve.rs @@ -14,6 +14,9 @@ use std::path::{Path, PathBuf}; use mvm_contract::protocol::vm_backend::BackendKind; +use mvm_core::registry_pack::AuthenticatedRegistryManifest; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::VerifiedComponentLayerInputs; use super::merge::{Layer, ResolvedPolicy, merge_layers}; use super::model::{GroupFile, HostArch, HostOs, PolicyBody, ProfileFile, WhenBlock}; @@ -224,6 +227,193 @@ pub fn resolve( Ok(resolved) } +/// A held, publisher-authenticated pack snapshot; raw policy bytes are not accepted here. +#[derive(Debug, Clone, Copy)] +pub enum VerifiedPolicyInputs<'a> { + /// Schema 3 application component. + Application(&'a VerifiedApplicationComponentInputs), + /// Schema 4 runtime or tool component. + Component(&'a VerifiedComponentLayerInputs), +} + +/// Interpret one signed pack's policy *request*, without granting it host authority. +/// +/// Only self-contained documents are supported: every external profile or group +/// reference is refused, even when a conditional block would not match. A caller +/// must separately enforce host authorization before admitting any requested grant. +/// +/// # Errors +/// +/// Refuses stale or changed verified inputs, malformed documents, references, +/// and policy requests rejected by [`merge_layers`]. +pub fn resolve_verified_policy( + inputs: VerifiedPolicyInputs<'_>, + platform: Platform, +) -> Result { + resolve_verified_policies(&[inputs], platform) +} + +/// Merge an ordered graph of signed pack policy requests in one pass. +/// +/// Inputs must be supplied in graph order. This is not a host grant: callers +/// must separately enforce host authorization for the resulting requests. +/// +/// # Errors +/// +/// Refuses any stale or changed input, unresolved reference, malformed document, +/// or policy conflict across packs. +pub fn resolve_verified_policies( + inputs: &[VerifiedPolicyInputs<'_>], + platform: Platform, +) -> Result { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + for input in inputs { + let (reference, pin, group, profile) = match input { + VerifiedPolicyInputs::Application(inputs) => { + inputs + .reverify() + .map_err(|error| PolicyError::new("pack", error.to_string()))?; + let descriptor = inputs.descriptor(); + ( + descriptor.reference().to_string(), + descriptor.manifest_sha256().as_str().to_string(), + inputs.group(), + inputs.profile(), + ) + } + VerifiedPolicyInputs::Component(inputs) => { + inputs + .reverify() + .map_err(|error| PolicyError::new("pack", error.to_string()))?; + let descriptor = inputs.descriptor(); + ( + descriptor.reference().to_string(), + descriptor.manifest_sha256().as_str().to_string(), + inputs.group(), + inputs.profile(), + ) + } + }; + append_signed_documents( + &reference, + &pin, + group, + profile, + platform, + &mut layers, + &mut backend_conditioned, + )?; + } + let mut resolved = merge_layers(&layers)?; + resolved.backend_conditioned = backend_conditioned; + Ok(resolved) +} + +fn append_signed_documents( + reference: &str, + pin: &str, + group: Option<&[u8]>, + profile: Option<&[u8]>, + platform: Platform, + layers: &mut Vec, + backend_conditioned: &mut bool, +) -> Result<(), PolicyError> { + let label = format!("pack `{reference}` manifest sha256:{pin}"); + let group: Option = group + .map(|bytes| parse_signed_document(bytes, &format!("{label} group"))) + .transpose()?; + let profile: Option = profile + .map(|bytes| parse_signed_document(bytes, &format!("{label} profile"))) + .transpose()?; + if let Some(group) = group { + layers.push(Layer { + label: format!("{label} group"), + file: None, + origin: LayerOrigin::Pack, + body: group.body(), + }); + } + if let Some(profile) = profile { + if !profile.extends.is_empty() { + return Err( + PolicyError::new(&label, "signed profile dependencies are not resolved") + .at_key("extends"), + ); + } + if !profile.groups.include.is_empty() || !profile.groups.exclude.is_empty() { + return Err( + PolicyError::new(&label, "signed group references are not resolved") + .at_key("groups"), + ); + } + for (index, when) in profile.when.iter().enumerate() { + if !when.include.is_empty() || !when.exclude.is_empty() { + return Err(PolicyError::new( + &label, + "signed conditional group references are not resolved", + ) + .at_key(format!("when[{index}]"))); + } + if !when.backend.is_empty() { + *backend_conditioned = true; + } + if platform.matches(when) && !when.overrides.is_empty() { + layers.push(Layer { + label: format!("{label} when[{index}]"), + file: None, + origin: LayerOrigin::Pack, + body: when.overrides.clone(), + }); + } + } + if !profile.overrides.is_empty() { + layers.push(Layer { + label: format!("{label} overrides"), + file: None, + origin: LayerOrigin::Pack, + body: profile.overrides, + }); + } + } + Ok(()) +} + +#[cfg(test)] +fn resolve_signed_documents( + reference: &str, + pin: &str, + group: Option<&[u8]>, + profile: Option<&[u8]>, + platform: Platform, +) -> Result { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + append_signed_documents( + reference, + pin, + group, + profile, + platform, + &mut layers, + &mut backend_conditioned, + )?; + let mut resolved = merge_layers(&layers)?; + resolved.backend_conditioned = backend_conditioned; + Ok(resolved) +} + +fn parse_signed_document( + bytes: &[u8], + label: &str, +) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|error| PolicyError::new(label, format!("invalid UTF-8: {error}")))?; + DocFormat::Toml + .parse(text) + .map_err(|error| PolicyError::new(label, error)) +} + /// Resolve a single file that is a profile or, failing that, a group, as a /// user-authored layer. What `mvmctl policy validate PATH` checks. /// diff --git a/crates/mvm-client/src/policy_profiles/resolve_tests.rs b/crates/mvm-client/src/policy_profiles/resolve_tests.rs index 45627e2367..de84072060 100644 --- a/crates/mvm-client/src/policy_profiles/resolve_tests.rs +++ b/crates/mvm-client/src/policy_profiles/resolve_tests.rs @@ -52,6 +52,234 @@ fn cli(raw: &[&str]) -> Vec { raw.iter().map(ToString::to_string).collect() } +#[test] +fn signed_snapshot_preserves_pin_origin_and_merges_denies_and_minima() { + let resolved = resolve_signed_documents( + "runtime/python@3.0.0", + "abc123", + Some(b"[network]\nallow = [\"blocked.test\", \"open.test\"]\n[resources]\nmax_cpus = 4\n"), + Some(b"[overrides.network]\ndeny = [\"blocked.test\"]\n[overrides.resources]\nmax_cpus = 2\n"), + Platform::default(), + ) + .unwrap(); + assert_eq!(resolved.policy.network.allow, ["open.test:443"]); + assert_eq!(resolved.policy.resources.max_cpus, Some(2)); + assert!(resolved.layers.iter().all(|layer| { + layer.origin == LayerOrigin::Pack + && layer.label.contains("runtime/python@3.0.0") + && layer.label.contains("sha256:abc123") + && layer.file.is_none() + })); +} + +#[test] +fn signed_snapshot_is_never_a_user_escape_hatch_or_share_grant() { + for text in [ + "[env]\nreadmit = [\"PATH\"]\n", + "[[shares.mount]]\nhost = \"/host\"\nguest = \"/guest\"\n", + ] { + let resolved = resolve_signed_documents( + "tool/git@1.0.0", + "pin", + Some(text.as_bytes()), + None, + Platform::default(), + ) + .unwrap(); + assert!(resolved.policy.env.readmit.is_empty()); + assert!(resolved.policy.shares.mount.is_empty()); + assert!(resolved.notes.iter().any(|note| note.contains("ignored"))); + } +} + +#[test] +fn signed_snapshot_refuses_every_unresolved_reference_even_in_inactive_conditions() { + for (text, key) in [ + ("extends = \"default\"\n", "extends"), + ("[groups]\ninclude = [\"offline\"]\n", "groups"), + ("[groups]\nexclude = [\"offline\"]\n", "groups"), + ( + "[[when]]\nos = \"linux\"\ninclude = [\"offline\"]\n", + "when[0]", + ), + ( + "[[when]]\nos = \"linux\"\nexclude = [\"offline\"]\n", + "when[0]", + ), + ] { + let err = resolve_signed_documents( + "runtime/python@3.0.0", + "pin", + None, + Some(text.as_bytes()), + Platform::default(), + ) + .unwrap_err(); + assert_eq!(err.key.as_deref(), Some(key), "{text}: {err}"); + } +} + +#[test] +fn signed_snapshot_refuses_invalid_bytes_and_merges_block() { + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(&[0xff]), + None, + Platform::default() + ) + .is_err() + ); + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"unknown = true\n"), + None, + Platform::default() + ) + .is_err() + ); + let blocked = resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"[network]\nblock = true\n"), + Some(b"[overrides.network]\nallow = [\"conditional.test\"]\n"), + Platform::default(), + ) + .unwrap(); + assert_eq!(blocked.policy.network.block, Some(true)); + assert!(blocked.policy.network.allow.is_empty()); + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"[network]\nblock = true\n"), + Some(b"[overrides.network]\nblock = false\n"), + Platform::default(), + ) + .is_err() + ); +} + +#[test] +fn signed_snapshot_applies_only_matching_conditional_overrides() { + let profile = b"[[when]]\nos = \"linux\"\n[when.overrides.network]\ndeny = [\"linux.test\"]\n\ + [[when]]\nos = \"macos\"\n[when.overrides.network]\ndeny = [\"mac.test\"]\n"; + let resolved = resolve_signed_documents( + "runtime/python@3.0.0", + "pin", + Some(b"[network]\nallow = [\"linux.test\", \"mac.test\"]\n"), + Some(profile), + Platform { + os: Some(super::super::model::HostOs::Linux), + ..Platform::default() + }, + ) + .unwrap(); + assert_eq!(resolved.policy.network.allow, ["mac.test:443"]); + assert_eq!(resolved.layers.len(), 2); +} + +#[test] +fn ordered_signed_packs_merge_denies_and_minima_across_component_boundaries() { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + for (reference, pin, group) in [ + ( + "runtime/base@1.0.0", + "first", + "[network]\nallow = [\"blocked.test\", \"open.test\"]\n[resources]\nmax_cpus = 4\n", + ), + ( + "tool/guard@1.0.0", + "second", + "[network]\ndeny = [\"blocked.test\"]\n[resources]\nmax_cpus = 2\n", + ), + ( + "app/workload@1.0.0", + "third", + "[network]\nallow = [\"blocked.test\", \"later.test\"]\n[resources]\nmax_cpus = 3\n", + ), + ] { + append_signed_documents( + reference, + pin, + Some(group.as_bytes()), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + } + let resolved = merge_layers(&layers).unwrap(); + assert_eq!( + resolved.policy.network.allow, + ["open.test:443", "later.test:443"] + ); + assert_eq!(resolved.policy.resources.max_cpus, Some(2)); + assert_eq!(resolved.layers.len(), 3); + for (layer, pin) in resolved.layers.iter().zip(["first", "second", "third"]) { + assert_eq!(layer.origin, LayerOrigin::Pack); + assert!(layer.label.contains(&format!("sha256:{pin}"))); + } +} + +#[test] +fn signed_pack_cannot_unblock_another_or_resolve_a_later_dependency() { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + append_signed_documents( + "runtime/base@1.0.0", + "first", + Some(b"[network]\nblock = true\n"), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + append_signed_documents( + "tool/guard@1.0.0", + "second", + Some(b"[network]\nallow = [\"outside.test\"]\n"), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + let blocked = merge_layers(&layers).unwrap(); + assert_eq!(blocked.policy.network.block, Some(true)); + assert!(blocked.policy.network.allow.is_empty()); + + let mut conflicting = layers.clone(); + append_signed_documents( + "app/workload@1.0.0", + "third", + None, + Some(b"[overrides.network]\nblock = false\n"), + Platform::default(), + &mut conflicting, + &mut backend_conditioned, + ) + .unwrap(); + assert!(merge_layers(&conflicting).is_err()); + let err = append_signed_documents( + "app/workload@1.0.0", + "third", + None, + Some(b"extends = \"runtime/base@1.0.0\"\n"), + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap_err(); + assert_eq!(err.key.as_deref(), Some("extends")); +} + // ---- built-ins ------------------------------------------------------------ #[test] diff --git a/crates/mvm-client/src/portable_boot_assets.rs b/crates/mvm-client/src/portable_boot_assets.rs index f9176af467..4e4b2e1a82 100644 --- a/crates/mvm-client/src/portable_boot_assets.rs +++ b/crates/mvm-client/src/portable_boot_assets.rs @@ -29,10 +29,12 @@ pub struct PreparedPortableBootAssets { pub(crate) initrd: PathBuf, pub(crate) binaries: MvmRuntimeBinaries, pub(crate) release_proof: RuntimeReleaseProof, + #[cfg(feature = "pr-test-guest-runtime")] + pub(crate) pr_test_proof: Option, pub(crate) checksum_signature_bundle: PathBuf, pub(crate) runtime: EmbeddedCliBootAssets, _prepared: VerifiedRuntimeArtifacts, - publisher: RetainedPublisherProof, + publisher: Option, home: PathBuf, bindings: Vec<(PathBuf, String, u64)>, } @@ -40,6 +42,45 @@ pub struct PreparedPortableBootAssets { impl PreparedPortableBootAssets { pub fn acquire(arch: GuestArch) -> Result { let image_set = PublishedImageSet::acquire_workload()?; + Self::acquire_with_set(Some(image_set), arch) + } + + /// Acquire only the signed CLI guest runtime. An explicitly selected + /// local-development Linux source supplies its own kernel, not this + /// build's signed default image set. + pub fn acquire_local(arch: GuestArch) -> Result { + Self::acquire_with_set(None, arch) + } + + /// Explicit PR-only input: genuine keyless-signed archive and checksum, + /// never a substitute for a published CLI release or a production run. + #[cfg(feature = "pr-test-guest-runtime")] + pub fn acquire_pr_test_local( + arch: GuestArch, + directory: &Path, + pull_request: u64, + ) -> Result { + anyhow::ensure!( + mvm_build::artifact_acquisition::compiled_channel().permits_automatic_builds(), + "PR test runtime cannot run in a release-channel binary" + ); + let asset = mvm_build::guest_bins::guest_bins_archive_name(env!("CARGO_PKG_VERSION")); + let archive = directory.join(&asset); + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: archive.clone(), + checksum: directory.join(format!("{asset}.sha256")), + signature_bundle: directory.join(format!("{asset}.bundle")), + archive_sha256: sha256_file(&archive)?, + version: env!("CARGO_PKG_VERSION").to_string(), + arch, + }, + pull_request, + }; + Self::from_source_with_pr_test(None, arch, &proof.input, Some(&proof)) + } + + fn acquire_with_set(image_set: Option, arch: GuestArch) -> Result { let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()); let runtime = mvm_build::guest_runtime::resolve_or_download_guest_runtime_quartet( &cache, @@ -49,36 +90,84 @@ impl PreparedPortableBootAssets { let proof = runtime .release_proof .context("portable creation requires a signed CLI runtime release")?; - Self::from_set(image_set, arch, &proof) + Self::from_source(image_set, arch, &proof) } + #[cfg(test)] fn from_set( image_set: PublishedImageSet, arch: GuestArch, source_proof: &RuntimeReleaseProof, + ) -> Result { + Self::from_source(Some(image_set), arch, source_proof) + } + + fn from_source( + image_set: Option, + arch: GuestArch, + source_proof: &RuntimeReleaseProof, + ) -> Result { + Self::from_source_with_pr_test(image_set, arch, source_proof, None) + } + + fn from_source_with_pr_test( + image_set: Option, + arch: GuestArch, + source_proof: &RuntimeReleaseProof, + #[cfg(feature = "pr-test-guest-runtime")] pr_test: Option< + &mvm_core::protocol::runtime_release::PrTestRuntimeProof, + >, + #[cfg(not(feature = "pr-test-guest-runtime"))] _pr_test: Option<&()>, ) -> Result { anyhow::ensure!( source_proof.version == env!("CARGO_PKG_VERSION") && source_proof.arch == arch, "portable runtime proof differs from the selected CLI version or architecture" ); - verify_runtime_release_proof(source_proof, &source_proof.version, arch).with_context( - || { - format!( - "authenticate selected runtime archive {}", - source_proof.archive.display() + #[cfg(feature = "pr-test-guest-runtime")] + let checked = match pr_test { + Some(pr_test) => { + anyhow::ensure!( + &pr_test.input == source_proof && image_set.is_none(), + "PR test input is not a local runtime selection" + ); + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + pr_test, + &source_proof.version, + arch, ) - }, - )?; - let publisher = image_set.retained_publisher_proof()?; + } + None => verify_runtime_release_proof(source_proof, &source_proof.version, arch), + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let checked = verify_runtime_release_proof(source_proof, &source_proof.version, arch); + checked.with_context(|| { + format!( + "authenticate selected runtime archive {}", + source_proof.archive.display() + ) + })?; + let publisher = image_set + .as_ref() + .map(PublishedImageSet::retained_publisher_proof) + .transpose()?; let home = mvm_core::config::mvm_home_strict()?; let directory = tempfile::tempdir().context("stage portable release boot assets")?; mvm_core::config::create_private_dir(directory.path())?; let root = directory.path(); - let kernel_member = - mvm_core::plan::bundle::select_cli_boot_kernel(image_set.manifest(), arch)?; - let kernel_artifact = single_artifact(kernel_member)?; let kernel = root.join("vmlinux"); - image_set.fetch_artifact(kernel_artifact, &kernel)?; + let kernel_binding = if let Some(image_set) = &image_set { + let kernel_member = + mvm_core::plan::bundle::select_cli_boot_kernel(image_set.manifest(), arch)?; + let kernel_artifact = single_artifact(kernel_member)?; + image_set.fetch_artifact(kernel_artifact, &kernel)?; + Some(( + kernel.clone(), + kernel_artifact.sha256.as_str().to_owned(), + kernel_artifact.size, + )) + } else { + None + }; let archive = root.join("guest-runtime.tar.gz"); let checksum = root.join("guest-runtime.sha256"); let signature_bundle = root.join("guest-runtime.bundle"); @@ -101,8 +190,40 @@ impl PreparedPortableBootAssets { signature_bundle, ..source_proof.clone() }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test_proof = + pr_test.map( + |proof| mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: release_proof.clone(), + pull_request: proof.pull_request, + }, + ); + #[cfg(feature = "pr-test-guest-runtime")] + let verified = match &pr_test_proof { + Some(proof) => mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + &source_proof.version, + arch, + )?, + None => verify_runtime_release_proof(&release_proof, &source_proof.version, arch)?, + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] let verified = verify_runtime_release_proof(&release_proof, &source_proof.version, arch)?; let cache = root.join("runtime-cache"); + #[cfg(feature = "pr-test-guest-runtime")] + let guest_runtime = if let Some(proof) = &pr_test_proof { + mvm_build::guest_runtime::install_pr_test_guest_runtime_archive(&cache, proof)? + } else { + mvm_build::guest_runtime::install_signed_guest_runtime_archive( + &cache, + &release_proof.archive, + &release_proof.checksum, + &release_proof.signature_bundle, + &release_proof.version, + arch, + )? + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] let guest_runtime = mvm_build::guest_runtime::install_signed_guest_runtime_archive( &cache, &release_proof.archive, @@ -150,6 +271,16 @@ impl PreparedPortableBootAssets { overlay_roothash: declaration.overlay_roothash.clone(), protocol_version: declaration.protocol_version, min_supported_protocol_version: declaration.min_supported_protocol_version, + pr_test_pull_request: { + #[cfg(feature = "pr-test-guest-runtime")] + { + pr_test_proof.as_ref().map(|proof| proof.pull_request) + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + None + } + }, }; let bin = guest_runtime.root.join(arch.to_string()).join("bin"); let binaries = MvmRuntimeBinaries { @@ -158,11 +289,7 @@ impl PreparedPortableBootAssets { egress_client: bin.join("mvm-egress-client"), entrypoint_runner: bin.join("mvm-oci-entrypoint"), }; - let mut bindings = vec![( - kernel.clone(), - kernel_artifact.sha256.as_str().to_owned(), - kernel_artifact.size, - )]; + let mut bindings = kernel_binding.into_iter().collect::>(); for path in [ &runtime_overlay, &runtime_overlay_verity, @@ -190,6 +317,8 @@ impl PreparedPortableBootAssets { initrd, binaries, release_proof, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof, checksum_signature_bundle, runtime, _prepared: prepared, @@ -204,20 +333,30 @@ impl PreparedPortableBootAssets { mvm_core::config::mvm_home_strict()? == self.home, "portable producer closure belongs to another home" ); - let current = mvm_core::image_set_revocation::ImageSetRevocationStore::in_mvm_home() - .load(chrono::Utc::now())?; - anyhow::ensure!( - current.checkpoint() == self.publisher.revocations().verified().checkpoint(), - "portable publisher policy advanced; capture a fresh closure" - ); - let request = mvm_core::image_set::ImageSetVerification::new( - self.publisher.root().manifest_bytes(), - self.publisher.root().signature_bundle(), - &mvm_core::image_set::image_train_lock().image_set, - Path::new(""), - ) - .with_revocations(¤t); - mvm_core::image_set::verify_image_set_root(&request)?; + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &self.pr_test_proof { + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + GuestArch::host(), + )?; + } + if let Some(publisher) = &self.publisher { + let current = mvm_core::image_set_revocation::ImageSetRevocationStore::in_mvm_home() + .load(chrono::Utc::now())?; + anyhow::ensure!( + current.checkpoint() == publisher.revocations().verified().checkpoint(), + "portable publisher policy advanced; capture a fresh closure" + ); + let request = mvm_core::image_set::ImageSetVerification::new( + publisher.root().manifest_bytes(), + publisher.root().signature_bundle(), + &mvm_core::image_set::image_train_lock().image_set, + Path::new(""), + ) + .with_revocations(¤t); + mvm_core::image_set::verify_image_set_root(&request)?; + } verify_bindings(&self.bindings) } @@ -232,7 +371,12 @@ impl PreparedPortableBootAssets { /// Original signed Linux root selected for the portable boot closure. pub fn image_set_manifest_sha256(&self) -> &mvm_core::packs::Sha256Hex { - self.publisher.root().root().manifest_sha256() + self.publisher + .as_ref() + .expect("signed boot closure has a Linux publisher") + .root() + .root() + .manifest_sha256() } /// Private producer input directories, for retention diagnostics only. @@ -243,6 +387,10 @@ impl PreparedPortableBootAssets { /// Check the built slot rather than silently replacing its selected kernel. pub fn verify_build_kernel(&self, kernel: &Path) -> Result<()> { + anyhow::ensure!( + self.publisher.is_some(), + "local runtime-only closure cannot attest a signed Linux kernel" + ); self.verify_integrity()?; verify_kernel_selection(kernel, &self.bindings[0].1)?; Ok(()) @@ -316,6 +464,58 @@ impl PreparedPortableBootAssets { signer: &dyn mvm_bundler::BundleSigner, ) -> Result { self.verify_build_kernel(Path::new(inputs.vmlinux))?; + let publisher = self + .publisher + .as_ref() + .context("signed boot closure has no Linux publisher")?; + self.stage_selected( + inputs, + signer, + publisher.root().manifest_bytes(), + publisher.root().root().manifest_sha256(), + None, + ) + } + + /// Seal an explicitly selected local-development kernel and base while + /// retaining the separately signed CLI guest runtime unchanged. + pub fn stage_local( + &self, + inputs: &mvm_bundler::BundleExportInputs<'_>, + signer: &dyn mvm_bundler::BundleSigner, + manifest_bytes: &[u8], + proof: mvm_core::plan::bundle::LocalDevelopmentBaseProof, + ) -> Result { + self.verify_integrity()?; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + self.pr_test_proof.is_none() || inputs.profile == Some("dev"), + "PR test runtime cannot be sealed with a production profile" + ); + anyhow::ensure!( + inputs.profile == Some("dev") + && inputs.local_build + && proof.manifest_artifact == self.runtime.manifest_artifact, + "local base requires a host-signed development bundle" + ); + let manifest_sha256 = mvm_core::packs::Sha256Hex::from_bytes(manifest_bytes); + self.stage_selected( + inputs, + signer, + manifest_bytes, + &manifest_sha256, + Some(proof), + ) + } + + fn stage_selected( + &self, + inputs: &mvm_bundler::BundleExportInputs<'_>, + signer: &dyn mvm_bundler::BundleSigner, + manifest_bytes: &[u8], + manifest_sha256: &mvm_core::packs::Sha256Hex, + local: Option, + ) -> Result { verify_export_selection(inputs, &self.runtime.arch, &sha256_file(&self.initrd)?)?; let files = [ ( @@ -349,15 +549,23 @@ impl PreparedPortableBootAssets { ]; let mut selected = inputs.clone(); selected.cli_boot_assets = Some(mvm_bundler::CliBootAssetsInputs { - manifest_bytes: self.publisher.root().manifest_bytes(), - manifest_sha256: self.publisher.root().root().manifest_sha256(), + manifest_bytes, + manifest_sha256, runtime: &self.runtime, artifacts: &files, }); - selected.canonical_linux_proof = Some(mvm_bundler::CanonicalLinuxProofInputs { - root: self.publisher.root(), - revocations: self.publisher.revocations(), - }); + if let Some(proof) = local { + selected.local_development_base_proof = Some(proof); + } else { + let publisher = self + .publisher + .as_ref() + .context("signed boot closure has no Linux publisher")?; + selected.canonical_linux_proof = Some(mvm_bundler::CanonicalLinuxProofInputs { + root: publisher.root(), + revocations: publisher.revocations(), + }); + } let output = mvm_bundler::stage_bundle_with_signer(&selected, signer)?; if let Err(error) = self.verify_integrity() { return Err(output.abort(error)); @@ -459,6 +667,62 @@ mod tests { use mvm_core::image_set::{ImageSetRole, MemberTarget, WorkloadImageProfile}; use mvm_core::util::test_env::TestEnv; + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + #[ignore = "requires the PR workflow's real keyless-signed guest runtime quartet"] + fn pr_test_local_acquisition_checks_assembled_runtime_independently() { + let directory = PathBuf::from( + std::env::var_os("MVM_PR_TEST_RUNTIME_DIR").expect("PR runtime quartet directory"), + ); + let pull_request: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .expect("PR number") + .parse() + .expect("numeric PR number"); + let home = tempfile::tempdir().unwrap(); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let assets = PreparedPortableBootAssets::acquire_pr_test_local( + GuestArch::host(), + &directory, + pull_request, + ) + .expect("PR-signed runtime assembles from the exact archive"); + assert_eq!(assets.runtime.pr_test_pull_request, Some(pull_request)); + assets + .verify_integrity() + .expect("assembled runtime unchanged"); + let proof = assets + .pr_test_proof + .as_ref() + .expect("distinct PR provenance"); + let verified = mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + GuestArch::host(), + ) + .unwrap(); + let mut selection = RuntimeArtifactSelection { + arch: GuestArch::host(), + overlay: &assets.runtime_overlay, + overlay_verity: &assets.runtime_overlay_verity, + overlay_roothash: &assets.runtime.overlay_roothash, + initramfs: &assets.initrd, + minimum_protocol: mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + host_protocol: mvm_runtime::runtime_evidence::GUEST_RPC_PROTOCOL_VERSION, + }; + verified.verify_selected_artifacts(&selection).unwrap(); + let wrong = tempfile::tempdir().unwrap(); + let altered_overlay = wrong.path().join("overlay"); + let altered_initrd = wrong.path().join("initrd"); + std::fs::write(&altered_overlay, b"altered overlay").unwrap(); + std::fs::write(&altered_initrd, b"altered initramfs").unwrap(); + selection.overlay = &altered_overlay; + assert!(verified.verify_selected_artifacts(&selection).is_err()); + selection.overlay = &assets.runtime_overlay; + selection.initramfs = &altered_initrd; + assert!(verified.verify_selected_artifacts(&selection).is_err()); + } + #[test] #[ignore = "requires genuine signed runtime, published Linux proofs, and an existing sealed workload"] fn signed_release_exports_real_canonical_workload() { diff --git a/crates/mvm-contract/src/lib.rs b/crates/mvm-contract/src/lib.rs index 1c0b1ad2b0..95fa6eed9f 100644 --- a/crates/mvm-contract/src/lib.rs +++ b/crates/mvm-contract/src/lib.rs @@ -62,6 +62,9 @@ pub mod pack_spec; /// Versioned composition authoring, separate from the existing Nix pack contract. #[cfg(feature = "protocol")] pub mod pack_spec_v2; +/// Ordered signed-pack graph and independent, explicit command selection. +#[cfg(feature = "protocol")] +pub mod pack_spec_v3; /// Names one workload uses to address another over the existing /// host-mediated egress path. #[cfg(feature = "protocol")] diff --git a/crates/mvm-contract/src/pack_spec_v3.rs b/crates/mvm-contract/src/pack_spec_v3.rs new file mode 100644 index 0000000000..f5a2fa1ab4 --- /dev/null +++ b/crates/mvm-contract/src/pack_spec_v3.rs @@ -0,0 +1,363 @@ +//! Ordered signed-pack references and separately selected launch commands. +//! +//! These are requests, not verified packs, a lock, a build result or permission +//! to launch. Consumers must verify each referenced manifest signature, digest, +//! publisher, revocation and compatibility before materializing the graph. +//! Neither a runtime nor a tool starts a service by being present in the graph. + +use alloc::{collections::BTreeSet, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; + +use crate::{ + pack_lock::lower_hex, + pack_spec::{PackIdentity, PackTarget, relative_path}, +}; + +/// New authored graph schema; existing v1 and v2 formats remain independent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComposedGraphSchema { + #[serde(rename = "mvm.pack-spec/v3")] + V3, +} + +/// A reference to the exact bytes of a signed registry manifest. +/// +/// This does not attest to a valid signature: the consumer must verify it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedPackPin { + pub name: String, + pub version: String, + pub manifest_sha256: String, +} + +impl SignedPackPin { + /// Refuse ambiguous coordinates and non-canonical SHA-256 digests. + /// + /// # Errors + /// + /// Returns an error for an invalid name, version or digest. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + PackIdentity { + name: self.name.clone(), + version: self.version.clone(), + } + .validate() + .map_err(|_| ComposedGraphError::Pin)?; + if self.version.contains('+') || !lower_hex(&self.manifest_sha256, 64) { + return Err(ComposedGraphError::Pin); + } + Ok(()) + } +} + +/// Ordered input layers. Entrypoints come from verified application manifests, +/// never from authored graph data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum GraphNode { + Application { pin: SignedPackPin }, + Runtime { pin: SignedPackPin }, + Tool { pin: SignedPackPin }, +} + +impl GraphNode { + /// The pinned registry manifest for this layer. + pub fn pin(&self) -> &SignedPackPin { + match self { + Self::Application { pin, .. } | Self::Runtime { pin } | Self::Tool { pin } => pin, + } + } +} + +/// An ordered graph of explicitly named, digest-pinned registry packs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ComposedGraphWire")] +pub struct ComposedGraph { + pub schema: ComposedGraphSchema, + pub graph: Vec, +} + +/// One authored invocation binds the ordered graph, target and command request. +/// +/// Base selection and host grants remain launch inputs, not pack declarations. +/// This document does not attest to publisher signatures or authorize a boot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ComposedRunWire")] +pub struct ComposedRunRequest { + pub schema: ComposedRunSchema, + pub target: PackTarget, + pub graph: ComposedGraph, + pub entrypoint: EntrypointRequest, +} + +/// The single-file invocation format for the v3 graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComposedRunSchema { + #[serde(rename = "mvm.composed-run/v1")] + V1, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ComposedRunWire { + schema: ComposedRunSchema, + target: PackTarget, + graph: ComposedGraph, + entrypoint: EntrypointRequest, +} + +impl TryFrom for ComposedRunRequest { + type Error = ComposedGraphError; + + fn try_from(wire: ComposedRunWire) -> Result { + let request = Self { + schema: wire.schema, + target: wire.target, + graph: wire.graph, + entrypoint: wire.entrypoint, + }; + request.validate()?; + Ok(request) + } +} + +impl ComposedRunRequest { + /// Validate directly constructed invocations before acquiring any pack bytes. + /// + /// # Errors + /// Refuses invalid graph pins or command requests. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + self.graph.validate()?; + self.entrypoint.validate() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ComposedGraphWire { + schema: ComposedGraphSchema, + graph: Vec, +} + +impl TryFrom for ComposedGraph { + type Error = ComposedGraphError; + + fn try_from(wire: ComposedGraphWire) -> Result { + let graph = Self { + schema: wire.schema, + graph: wire.graph, + }; + graph.validate()?; + Ok(graph) + } +} + +/// The independent launch request schema; it cannot reinterpret a pack spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntrypointRequestSchema { + #[serde(rename = "mvm.entrypoint-request/v1")] + V1, +} + +/// Optional, explicit command sources; neither a runtime nor a tool auto-starts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "EntrypointRequestWire")] +pub struct EntrypointRequest { + pub schema: EntrypointRequestSchema, + /// Highest precedence, supplied by the runtime invocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_command_override: Option>, + /// User workload/config command, used when runtime did not override it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_command: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EntrypointRequestWire { + schema: EntrypointRequestSchema, + #[serde(default)] + runtime_command_override: Option>, + #[serde(default)] + workload_command: Option>, +} + +impl TryFrom for EntrypointRequest { + type Error = ComposedGraphError; + + fn try_from(wire: EntrypointRequestWire) -> Result { + let request = Self { + schema: wire.schema, + runtime_command_override: wire.runtime_command_override, + workload_command: wire.workload_command, + }; + request.validate()?; + Ok(request) + } +} + +/// Where the selected literal argv came from, without constructing a service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectedEntrypoint<'a> { + RuntimeOverride(&'a [String]), + Workload(&'a [String]), + Application { + pin: &'a SignedPackPin, + argv: &'a [String], + }, +} + +/// Validation errors do not echo command arguments or registry coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ComposedGraphError { + #[error("graph must contain between 1 and 128 layers including an application")] + Graph, + #[error("invalid or duplicate signed manifest pin")] + Pin, + #[error("pack kind conflicts with its registry namespace")] + Kind, + #[error("unsafe or empty launch command")] + Command, + #[error("could not serialize composed request")] + Serialization, +} + +impl ComposedGraph { + /// Validate directly constructed graphs before use, including every layer. + /// + /// # Errors + /// + /// Refuses missing applications, duplicate names and mismatched kinds. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + if self.graph.is_empty() + || self.graph.len() > 128 + || !self + .graph + .iter() + .any(|node| matches!(node, GraphNode::Application { .. })) + { + return Err(ComposedGraphError::Graph); + } + let mut names = BTreeSet::new(); + for node in &self.graph { + let pin = node.pin(); + pin.validate()?; + if !names.insert(pin.name.as_str()) { + return Err(ComposedGraphError::Pin); + } + let correct_kind = match node { + GraphNode::Application { .. } => { + !pin.name.starts_with("runtime/") && !pin.name.starts_with("tool/") + } + GraphNode::Runtime { .. } => pin.name.starts_with("runtime/"), + GraphNode::Tool { .. } => pin.name.starts_with("tool/"), + }; + if !correct_kind { + return Err(ComposedGraphError::Kind); + } + } + Ok(()) + } + + /// Select a command, or `None` if no source declared one. + /// + /// The runtime override wins over the workload/config command; otherwise + /// the last *verified declaring* application wins, not necessarily the + /// last layer. The caller supplies default argv from the verified signed + /// manifest for each pin, or `None` if that manifest has no default. + /// This callback is a trust boundary: the contract does not verify packs. + /// The caller must also admit the selected command before execution. + /// + /// # Errors + /// + /// Refuses invalid directly constructed graphs or requests, even if an + /// override would otherwise conceal an invalid application default. + pub fn select_entrypoint<'a>( + &'a self, + request: &'a EntrypointRequest, + mut application_default: impl FnMut( + &SignedPackPin, + ) -> Result, ComposedGraphError>, + ) -> Result>, ComposedGraphError> { + self.validate()?; + request.validate()?; + let mut last_default = None; + for node in &self.graph { + if let GraphNode::Application { pin } = node + && let Some(argv) = application_default(pin)? + { + validate_command(argv)?; + last_default = Some(SelectedEntrypoint::Application { pin, argv }); + } + } + if let Some(argv) = &request.runtime_command_override { + return Ok(Some(SelectedEntrypoint::RuntimeOverride(argv))); + } + if let Some(argv) = &request.workload_command { + return Ok(Some(SelectedEntrypoint::Workload(argv))); + } + Ok(last_default) + } + + /// Deterministic authored JSON, preserving semantic layer order. + /// + /// # Errors + /// + /// Refuses invalid graph data or serialization failure. + pub fn canonical_json(&self) -> Result, ComposedGraphError> { + self.validate()?; + let mut value = + serde_json::to_value(self).map_err(|_| ComposedGraphError::Serialization)?; + value.sort_all_objects(); + serde_json::to_vec(&value).map_err(|_| ComposedGraphError::Serialization) + } +} + +impl EntrypointRequest { + /// Validate both command sources even if one takes precedence. + /// + /// # Errors + /// + /// Refuses unsafe or empty argv in either source. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + for argv in [&self.runtime_command_override, &self.workload_command] + .into_iter() + .flatten() + { + validate_command(argv)?; + } + Ok(()) + } + + /// Deterministic request JSON, with absent commands omitted. + /// + /// # Errors + /// + /// Refuses invalid commands or serialization failure. + pub fn canonical_json(&self) -> Result, ComposedGraphError> { + self.validate()?; + let mut value = + serde_json::to_value(self).map_err(|_| ComposedGraphError::Serialization)?; + value.sort_all_objects(); + serde_json::to_vec(&value).map_err(|_| ComposedGraphError::Serialization) + } +} + +fn validate_command(argv: &[String]) -> Result<(), ComposedGraphError> { + let Some(program) = argv.first() else { + return Err(ComposedGraphError::Command); + }; + if argv.len() > 128 + || argv.iter().map(String::len).sum::() > 16 * 1024 + || !program.starts_with('/') + || !program.bytes().all(|byte| byte.is_ascii_graphic()) + || relative_path(&program[1..], false).is_err() + || argv + .iter() + .any(|argument| argument.is_empty() || argument.chars().any(char::is_control)) + { + return Err(ComposedGraphError::Command); + } + Ok(()) +} diff --git a/crates/mvm-contract/src/plan/bundle.rs b/crates/mvm-contract/src/plan/bundle.rs index 9683816e35..2840ba4a05 100644 --- a/crates/mvm-contract/src/plan/bundle.rs +++ b/crates/mvm-contract/src/plan/bundle.rs @@ -52,7 +52,9 @@ use crate::policy::security::AgentProfile; /// Version 6 separates the signed CLI runtime from the original Linux image set. /// Version 7 carries original canonical Linux publisher and revocation proofs. /// Version 8 binds a host-local composition to its authenticated input inventory. -pub const BUNDLE_SCHEMA_VERSION: u32 = 8; +/// Version 9 distinguishes a measured local development base from publisher proof. +/// Version 10 records a PR-only test-runtime signing ref in a host-signed development bundle. +pub const BUNDLE_SCHEMA_VERSION: u32 = 10; /// The first schema that may carry [`BundleMember::Environment`]. pub const ENVIRONMENT_MEMBER_MIN_SCHEMA: u32 = 5; @@ -69,6 +71,12 @@ pub const CANONICAL_LINUX_PROOF_MIN_SCHEMA: u32 = 7; /// The first schema that carries a host-local composition input inventory. pub const LOCAL_COMPOSITION_MIN_SCHEMA: u32 = 8; +/// The first schema that may carry measured, development-only base evidence. +pub const LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA: u32 = 9; + +/// A PR-workflow test runtime requires an explicit schema bump. +pub const PR_TEST_RUNTIME_MIN_SCHEMA: u32 = 10; + /// The first schema that may carry backend-neutral members. pub const BUNDLE_MEMBERS_MIN_SCHEMA: u32 = 3; @@ -204,6 +212,8 @@ pub enum BundleMember { /// Original canonical Linux root and revocation proof inventory references. /// Data to reverify at admission, never a publisher-authored permission. CanonicalLinuxProof(CanonicalLinuxProof), + /// A measured local source, not a publisher signature or release permission. + LocalDevelopmentBaseProof(LocalDevelopmentBaseProof), /// Development-only assembly inputs, not a publisher signature or a grant. /// The bundle's rootfs and verity declarations bind the measured output. LocalComposition(LocalComposition), @@ -232,8 +242,15 @@ impl BundleMember { pub const fn minimum_schema_version(&self) -> u32 { match self { Self::EmbeddedBootAssets { .. } => BOOT_ASSETS_MEMBER_MIN_SCHEMA, - Self::EmbeddedCliBootAssets(_) => CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, + Self::EmbeddedCliBootAssets(runtime) => { + if runtime.pr_test_pull_request.is_some() { + PR_TEST_RUNTIME_MIN_SCHEMA + } else { + CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA + } + } Self::CanonicalLinuxProof(_) => CANONICAL_LINUX_PROOF_MIN_SCHEMA, + Self::LocalDevelopmentBaseProof(_) => LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, Self::LocalComposition(_) => LOCAL_COMPOSITION_MIN_SCHEMA, Self::Environment(_) => ENVIRONMENT_MEMBER_MIN_SCHEMA, Self::EmbeddedImageSet { .. } @@ -262,6 +279,10 @@ pub struct EmbeddedCliBootAssets { pub overlay_roothash: String, pub protocol_version: u32, pub min_supported_protocol_version: u32, + /// Exact PR ref whose keyless workflow signed this synthetic runtime. + /// Never grants release provenance or substitutes for signature verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_test_pull_request: Option, } /// Archive inventory names, not host paths, trust roots or verification flags. @@ -277,6 +298,17 @@ pub struct CanonicalLinuxProof { pub revocation_signature_bundle_artifact: String, } +/// Exact local base manifest and external composition source for a same-host dev run. +/// The source path is never publisher authority; consumers rehash it at admission. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalDevelopmentBaseProof { + pub manifest_artifact: String, + pub composition_tar_sha256: String, + pub composition_tar_size: u64, + pub composition_tar_path: String, +} + /// Signed artifact names for the exact source lock and original layer evidence. /// External layer archives are not republished. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -545,14 +577,55 @@ mod tests { } #[test] - fn bundle_schema_version_is_eight() { + fn bundle_schema_version_is_ten() { // Pin the current version constant — bumps are deliberate; // a silent rev should trip this test. - assert_eq!(BUNDLE_SCHEMA_VERSION, 8); + assert_eq!(BUNDLE_SCHEMA_VERSION, 10); assert_eq!(ENVIRONMENT_MEMBER_MIN_SCHEMA, 5); assert_eq!(CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, 6); assert_eq!(CANONICAL_LINUX_PROOF_MIN_SCHEMA, 7); assert_eq!(LOCAL_COMPOSITION_MIN_SCHEMA, 8); + assert_eq!(LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, 9); + assert_eq!(PR_TEST_RUNTIME_MIN_SCHEMA, 10); + } + + #[test] + fn local_development_proof_is_closed_complete_and_requires_schema_nine() { + let value = serde_json::json!({ + "class": "local_development_base_proof", + "manifest_artifact": "boot-image-set.json", + "composition_tar_sha256": "a".repeat(64), + "composition_tar_size": 1024, + "composition_tar_path": "/local/base/rootfs.tar.gz" + }); + let member: BundleMember = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(member.minimum_schema_version(), 9); + assert_eq!(serde_json::to_value(member).unwrap(), value); + for field in value + .as_object() + .unwrap() + .keys() + .filter(|key| *key != "class") + { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "{field}" + ); + } + for field in [ + "publisher_verified", + "release", + "revocation_document_artifact", + ] { + let mut forged = value.clone(); + forged[field] = true.into(); + assert!( + serde_json::from_value::(forged).is_err(), + "{field}" + ); + } } #[test] @@ -625,6 +698,11 @@ mod tests { let member: BundleMember = serde_json::from_value(value.clone()).unwrap(); assert_eq!(member.minimum_schema_version(), 6); assert_eq!(serde_json::to_value(member).unwrap(), value); + let mut pr_test = value.clone(); + pr_test["pr_test_pull_request"] = 42.into(); + let member: BundleMember = serde_json::from_value(pr_test.clone()).unwrap(); + assert_eq!(member.minimum_schema_version(), 10); + assert_eq!(serde_json::to_value(member).unwrap(), pr_test); for field in value .as_object() .unwrap() diff --git a/crates/mvm-contract/tests/pack_spec_v3.rs b/crates/mvm-contract/tests/pack_spec_v3.rs new file mode 100644 index 0000000000..9e2bd990f6 --- /dev/null +++ b/crates/mvm-contract/tests/pack_spec_v3.rs @@ -0,0 +1,334 @@ +use mvm_contract::pack_spec::PackSpec; +use mvm_contract::pack_spec_v2::ComposablePackSpec; +use mvm_contract::pack_spec_v3::{ + ComposedGraph, ComposedGraphError, ComposedRunRequest, EntrypointRequest, GraphNode, + SelectedEntrypoint, SignedPackPin, +}; +use serde_json::{Value, json}; + +fn graph() -> Value { + let digest = "a".repeat(64); + json!({ + "schema": "mvm.pack-spec/v3", + "graph": [ + {"kind": "runtime", "pin": {"name": "runtime/python", "version": "2.0.1", "manifest_sha256": digest}}, + {"kind": "tool", "pin": {"name": "tool/grep", "version": "1.0.0", "manifest_sha256": digest}}, + {"kind": "application", "pin": {"name": "agent/first", "version": "1.0.0", "manifest_sha256": digest}}, + {"kind": "application", "pin": {"name": "agent/last", "version": "1.0.0", "manifest_sha256": digest}} + ] + }) +} + +fn request(runtime: Option>, workload: Option>) -> EntrypointRequest { + serde_json::from_value(json!({ + "schema": "mvm.entrypoint-request/v1", + "runtime_command_override": runtime, + "workload_command": workload, + })) + .unwrap() +} + +fn verified_default<'a>( + pin: &SignedPackPin, + first: &'a [String], + last: &'a [String], +) -> Option<&'a [String]> { + match pin.name.as_str() { + "agent/first" => Some(first), + "agent/last" => Some(last), + _ => None, + } +} + +#[test] +fn precedence_is_independent_of_graph_order_and_all_eight_combinations() { + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let first = vec!["/srv/app/first".into(), "--literal".into()]; + let last = vec!["/srv/app/last".into()]; + for runtime in [None, Some(vec!["/usr/bin/runtime"])] { + for workload in [None, Some(vec!["/usr/bin/workload"])] { + for declared in [false, true] { + let selection = request(runtime.clone(), workload.clone()); + let selected = graph + .select_entrypoint(&selection, |pin| { + Ok(if declared { + verified_default(pin, &first, &last) + } else { + None + }) + }) + .unwrap(); + match (runtime.is_some(), workload.is_some(), declared, selected) { + (true, _, _, Some(SelectedEntrypoint::RuntimeOverride(argv))) => { + assert_eq!(argv, ["/usr/bin/runtime"]); + } + (false, true, _, Some(SelectedEntrypoint::Workload(argv))) => { + assert_eq!(argv, ["/usr/bin/workload"]); + } + (false, false, true, Some(SelectedEntrypoint::Application { pin, argv })) => { + assert_eq!(pin.name, "agent/last"); + assert_eq!(argv, ["/srv/app/last"]); + } + (false, false, false, None) => {} + other => panic!("unexpected selection: {other:?}"), + } + } + } + } +} + +#[test] +fn last_declaring_application_wins_over_later_undeclared_application_and_tool() { + let mut doc = graph(); + let tool = doc["graph"][1].clone(); + doc["graph"].as_array_mut().unwrap().remove(1); + doc["graph"].as_array_mut().unwrap().push(tool); + let graph: ComposedGraph = serde_json::from_value(doc).unwrap(); + let selection = request(None, None); + let first = vec!["/srv/app/first".into(), "--literal".into()]; + assert!(matches!( + graph.select_entrypoint(&selection, |pin| { + Ok((pin.name == "agent/first").then_some(first.as_slice())) + }), + Ok(Some(SelectedEntrypoint::Application { pin, argv })) + if pin.name == "agent/first" && argv == ["/srv/app/first", "--literal"] + )); +} + +#[test] +fn no_implicit_service_or_tool_startup() { + let no_commands: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let selection = request(None, None); + assert_eq!( + no_commands.select_entrypoint(&selection, |_| Ok(None)), + Ok(None) + ); + let mut doc = graph(); + doc["graph"][2]["command"] = json!(["/srv/app/bogus"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][0]["command"] = json!(["/usr/bin/python3"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][1]["services"] = json!(["daemon"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["services"] = json!(["daemon"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"].as_array_mut().unwrap().remove(3); + let graph: ComposedGraph = serde_json::from_value(doc).unwrap(); + assert_eq!(graph.select_entrypoint(&selection, |_| Ok(None)), Ok(None)); + assert_eq!( + graph.select_entrypoint(&request(Some(vec!["/bin/explicit"]), None), |_| { + Err(ComposedGraphError::Pin) + }), + Err(ComposedGraphError::Pin), + "an unverified application cannot be hidden by a command override" + ); +} + +#[test] +fn pins_are_exact_signed_manifest_references_and_conflicting_names_are_rejected() { + for (field, bad) in [ + ("manifest_sha256", json!("A".repeat(64))), + ("manifest_sha256", json!("f".repeat(63))), + ("manifest_sha256", json!(format!("{}g", "a".repeat(63)))), + ("version", json!("latest")), + ("version", json!("1.0.0+local")), + ("name", json!("runtime/python@2.0.1")), + ] { + let mut doc = graph(); + doc["graph"][0]["pin"][field] = bad; + assert!( + serde_json::from_value::(doc).is_err(), + "{field}" + ); + } + for field in ["signature", "publisher", "path", "digest"] { + let mut doc = graph(); + doc["graph"][0]["pin"][field] = json!("unauthorized"); + assert!( + serde_json::from_value::(doc).is_err(), + "{field}" + ); + } + let mut doc = graph(); + let duplicate = doc["graph"][0].clone(); + doc["graph"].as_array_mut().unwrap().push(duplicate); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][0]["pin"]["version"] = json!("3.0.0"); + let duplicate = doc["graph"][0].clone(); + doc["graph"].as_array_mut().unwrap().push(duplicate); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][3]["pin"]["name"] = json!("agent/first"); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][3]["pin"]["manifest_sha256"] = json!("b".repeat(64)); + doc["graph"][3]["pin"]["name"] = json!("agent/first"); + assert!(serde_json::from_value::(doc).is_err()); +} + +#[test] +fn closed_versions_roles_and_unsafe_commands_fail_on_deserialize_and_direct_construction() { + for schema in ["mvm.pack-spec/v1", "mvm.pack-spec/v2", "mvm.pack-spec/v4"] { + let mut doc = graph(); + doc["schema"] = json!(schema); + assert!(serde_json::from_value::(doc).is_err()); + } + assert!(serde_json::from_value::(graph()).is_err()); + assert!(serde_json::from_value::(graph()).is_err()); + for invalid_graph in [json!([]), json!([graph()["graph"][0].clone()])] { + let mut doc = graph(); + doc["graph"] = invalid_graph; + assert!(serde_json::from_value::(doc).is_err()); + } + let mut doc = graph(); + doc["graph"][0]["pin"]["unexpected"] = json!(true); + assert!(serde_json::from_value::(doc).is_err()); + for (index, name) in [(0, "agent/first"), (1, "runtime/python"), (2, "tool/grep")] { + let mut doc = graph(); + doc["graph"][index]["pin"]["name"] = json!(name); + assert!(serde_json::from_value::(doc).is_err()); + } + for bad in [ + json!([]), + json!(["relative"]), + json!(["//usr/bin/app"]), + json!(["/srv/app/../app"]), + json!(["/srv/app/run\nnow"]), + json!(["/srv/app/run", "a\0b"]), + json!(["/srv/app/run", "x".repeat(16 * 1024)]), + ] { + let mut req = json!({"schema": "mvm.entrypoint-request/v1"}); + req["runtime_command_override"] = bad.clone(); + assert!(serde_json::from_value::(req).is_err()); + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let selection = request(Some(vec!["/bin/safe"]), None); + let argv: Vec = serde_json::from_value(bad).unwrap(); + assert_eq!( + graph.select_entrypoint(&selection, |_| Ok(Some(argv.as_slice()))), + Err(ComposedGraphError::Command), + "invalid verified default must not be hidden by override" + ); + } + let mut graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + if let GraphNode::Application { pin } = &mut graph.graph[2] { + pin.manifest_sha256 = "0".into(); + } + assert_eq!(graph.validate(), Err(ComposedGraphError::Pin)); + assert!(graph.canonical_json().is_err()); + let mut req = request(None, None); + req.workload_command = Some(vec!["relative".into()]); + assert!(req.validate().is_err()); + assert!(graph.select_entrypoint(&req, |_| Ok(None)).is_err()); + let mut req = request(Some(vec!["/bin/safe"]), None); + req.workload_command = Some(vec!["relative".into()]); + assert!(req.validate().is_err(), "lower-priority invalid command"); + assert_eq!( + serde_json::from_value::(json!({ + "schema": "mvm.entrypoint-request/v1", + "runtime_command_override": ["/usr/bin/sh", "-c", "echo allowed"] + })) + .unwrap() + .validate(), + Ok(()), + "explicit shell use is governed by downstream admission, not this contract" + ); + assert!( + serde_json::from_str::( + r#"{"schema":"mvm.entrypoint-request/v1","schema":"mvm.entrypoint-request/v1"}"# + ) + .is_err() + ); +} + +#[test] +fn canonical_roundtrip_preserves_order_and_no_command_does_not_materialize() { + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let bytes = graph.canonical_json().unwrap(); + let roundtrip: ComposedGraph = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(roundtrip, graph); + assert_eq!(roundtrip.graph, graph.graph); + let request = request(None, None); + let bytes = request.canonical_json().unwrap(); + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + request + ); + assert!( + serde_json::from_slice::(br#"{"schema":"mvm.entrypoint-request/v2"}"#) + .is_err() + ); + assert!( + serde_json::from_slice::( + br#"{"schema":"mvm.entrypoint-request/v1","services":[]}"# + ) + .is_err() + ); + let toml = r#" +schema = "mvm.pack-spec/v3" + +[[graph]] +kind = "application" +[graph.pin] +name = "agent/example" +version = "1.0.0" +manifest_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +"#; + let from_toml: ComposedGraph = toml::from_str(toml).unwrap(); + assert_eq!( + from_toml.graph[0].pin().name, + "agent/example", + "TOML and JSON share the same closed model" + ); + assert_eq!( + toml::from_str::( + "schema = \"mvm.entrypoint-request/v1\"\nworkload_command = [\"/srv/app/run\"]" + ) + .unwrap() + .workload_command, + Some(vec!["/srv/app/run".into()]) + ); +} + +#[test] +fn one_file_invocation_preserves_order_target_and_entrypoint_without_granting_services() { + let value = json!({ + "schema": "mvm.composed-run/v1", + "target": "x86_64-linux", + "graph": graph(), + "entrypoint": { + "schema": "mvm.entrypoint-request/v1", + "workload_command": ["/srv/app/run"] + } + }); + let request: ComposedRunRequest = serde_json::from_value(value.clone()).unwrap(); + request.validate().unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), value); + assert_eq!(request.graph.graph[0].pin().name, "runtime/python"); + assert_eq!( + request.entrypoint.workload_command.as_deref().unwrap(), + ["/srv/app/run"] + ); + + for (field, invalid) in [ + ("schema", json!("mvm.composed-run/v2")), + ("target", json!("wasm32-wasi")), + ] { + let mut changed = value.clone(); + changed[field] = invalid; + assert!( + serde_json::from_value::(changed).is_err(), + "{field}" + ); + } + let mut unknown = value.clone(); + unknown["services"] = json!(["unrequested-daemon"]); + assert!(serde_json::from_value::(unknown).is_err()); + let mut invalid = request; + invalid.graph.graph.clear(); + assert!(invalid.validate().is_err()); +} diff --git a/crates/mvm-core/Cargo.toml b/crates/mvm-core/Cargo.toml index 3f94fa32d3..719ded0cd2 100644 --- a/crates/mvm-core/Cargo.toml +++ b/crates/mvm-core/Cargo.toml @@ -170,6 +170,8 @@ client-remote = ["client", "dep:mvm-http"] # Expose `plan::signing::test_support` (shared `ExecutionPlan` fixtures) to # other crates' test builds without shipping the fixtures in a release build. test-support = [] +# PR-workflow guest runtime evidence; never enabled by ordinary builds. +pr-test-guest-runtime = [] # The hostd IPC async transport in `protocol` (mvmd ↔ mvm-hostd control # channel: read/write_frame + send/recv request/response). The only # async surface in mvm-core besides `manifest-verify`; gated so the diff --git a/crates/mvm-core/src/canonical_linux.rs b/crates/mvm-core/src/canonical_linux.rs index 7fe6d9c152..71d39fb65b 100644 --- a/crates/mvm-core/src/canonical_linux.rs +++ b/crates/mvm-core/src/canonical_linux.rs @@ -559,6 +559,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let mut members = vec![BundleMember::EmbeddedCliBootAssets(runtime)]; if canonical { diff --git a/crates/mvm-core/src/image_set.rs b/crates/mvm-core/src/image_set.rs index cbe2ea1351..60f9ae25fd 100644 --- a/crates/mvm-core/src/image_set.rs +++ b/crates/mvm-core/src/image_set.rs @@ -21,6 +21,7 @@ use crate::arch::GuestArch; use crate::kernel_format::KernelFormat; use crate::packs::{FlakeLockIdentity, SbomReference, Sha256Hex, SourceRevisionIdentity}; +pub mod base_compatibility; mod base_contract; mod boot_abi; mod checkout; diff --git a/crates/mvm-core/src/image_set/base_compatibility.rs b/crates/mvm-core/src/image_set/base_compatibility.rs new file mode 100644 index 0000000000..0a994c4b21 --- /dev/null +++ b/crates/mvm-core/src/image_set/base_compatibility.rs @@ -0,0 +1,1136 @@ +//! Verify a composable base descriptor against independently authenticated image-set bytes. +//! +//! The descriptor does not extend the image-set schema or grant itself authority. +//! Its unsigned form is usable only for local development. + +use std::collections::HashSet; + +use mvm_contract::guest_libc::GuestLibc; +use serde::Deserialize; +use thiserror::Error; + +use super::{ + ArtifactFormat, ArtifactName, BaseImageContract, BootProtocol, ImageSetManifest, ImageSetRole, + ImageSetVerification, LocalImageSetVerification, MemberTarget, VerifiedArtifact, + WorkloadImageProfile, verify_image_set_artifacts, verify_local_image_set, +}; +use crate::arch::GuestArch; +use crate::crypto::image_verify::verify_signed_payload_under_any_identity; +use crate::image_set_revocation::{ + ImageSetRevocationStoreError, VerifiedImageSetRevocations, load_image_set_revocations, +}; +use crate::packs::{KeylessTrust, PackRevocationChecker, RevocationStatus, Sha256Hex}; +#[cfg(feature = "host-state")] +use crate::registry_pack::application_component::ComponentRequirements; + +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +const MAX_BUNDLE_BYTES: usize = 1024 * 1024; +const KERNEL_CAPABILITIES: [&str; 8] = [ + "virtio_vsock", + "virtio_blk", + "dm_verity", + "pty", + "namespaces", + "cgroup_v2", + "fuse", + "overlayfs", +]; + +/// A versioned, syntactically checked declaration; this alone is not trust evidence. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BaseCompatibility { + schema: String, + image_set_manifest_sha256: Sha256Hex, + arch: GuestArch, + profile: WorkloadImageProfile, + kernel: ArtifactPin, + composition_tar: ArtifactPin, + base_image_contract: BaseImageContract, + boot_protocol: BootProtocol, + filesystem_abi: u32, + kernel_version: String, + libc_loaders: Vec, + kernel_capabilities: Vec, +} + +/// An exact published artifact pin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactPin { + name: ArtifactName, + sha256: Sha256Hex, + size: u64, +} + +/// A declared dynamic loader in the base filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LibcLoader { + libc: GuestLibc, + path: String, +} + +/// A separately signed descriptor tied to authenticated, revocation-checked member bytes. +#[derive(Debug)] +pub struct ProductionBaseCompatibility { + descriptor: BaseCompatibility, + descriptor_bytes: Vec, + signature_bundle: Vec, + kernel: VerifiedArtifact, + composition_tar: VerifiedArtifact, +} + +/// An unsigned descriptor tied to measured local member bytes, never production authority. +#[derive(Debug)] +pub struct DevelopmentBaseCompatibility { + descriptor: BaseCompatibility, + descriptor_bytes: Vec, + kernel: VerifiedArtifact, + composition_tar: VerifiedArtifact, +} + +impl ProductionBaseCompatibility { + /// The authenticated declaration. + pub fn descriptor(&self) -> &BaseCompatibility { + &self.descriptor + } + + /// Exact descriptor bytes authenticated by the detached signature. + pub fn descriptor_bytes(&self) -> &[u8] { + &self.descriptor_bytes + } + + /// Original detached signature bundle, for a consumer retaining cache evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// The two member files whose bytes were checked. + pub fn artifacts(&self) -> (&VerifiedArtifact, &VerifiedArtifact) { + (&self.kernel, &self.composition_tar) + } + + /// Recheck the held signed bytes against a fresh consumer-held image-set source + /// and the currently applied checkpointed revocation feed. Use the returned + /// artifacts rather than paths from the earlier verification. + /// + /// # Errors + /// Refuses a stale or missing feed, changed member bytes, or invalid signatures. + pub fn reverify( + &self, + image_set: &ImageSetVerification<'_>, + ) -> Result { + self.reverify_with( + image_set, + load_image_set_revocations, + Checks { + signature: verify_descriptor_signature, + image_set: verify_image_set_artifacts, + }, + ) + } + + fn reverify_with( + &self, + image_set: &ImageSetVerification<'_>, + load: impl FnOnce() -> Result, + checks: Checks, + ) -> Result { + let revocations = load()?; + verify_production_with( + &self.descriptor_bytes, + &self.signature_bundle, + image_set, + self.descriptor.arch, + self.descriptor.profile, + &revocations, + checks, + ) + } + + /// Check app or runtime/tool requirements against the authenticated declaration. + /// + /// # Errors + /// Refuses unknown, noncanonical or unsatisfied requirements. + #[cfg(feature = "host-state")] + pub fn check_component_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + self.descriptor.check_requirements(requirements, arch) + } +} + +impl DevelopmentBaseCompatibility { + /// The unsigned local declaration. Its kernel claims are not boot witnesses. + pub fn descriptor(&self) -> &BaseCompatibility { + &self.descriptor + } + + /// Exact unsigned local descriptor bytes; never production evidence. + pub fn descriptor_bytes(&self) -> &[u8] { + &self.descriptor_bytes + } + + /// The two measured local member files. + pub fn artifacts(&self) -> (&VerifiedArtifact, &VerifiedArtifact) { + (&self.kernel, &self.composition_tar) + } + + /// Recheck the held unsigned declaration against a fresh local checkout + /// snapshot and measured member bytes. This remains Development evidence. + /// + /// # Errors + /// Refuses a changed checkout, manifest, or selected member bytes. + pub fn reverify( + &self, + image_set: &LocalImageSetVerification<'_>, + ) -> Result { + verify_development_base_compatibility( + &self.descriptor_bytes, + image_set, + self.descriptor.arch, + self.descriptor.profile, + ) + } + + /// Check requirements against measured local bytes and unsigned base claims. + /// + /// # Errors + /// Refuses unknown, noncanonical or unsatisfied requirements; success remains Development. + #[cfg(feature = "host-state")] + pub fn check_component_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + self.descriptor.check_requirements(requirements, arch) + } +} + +impl BaseCompatibility { + /// Strictly parse the bounded v1 JSON and validate every declared value. + /// + /// # Errors + /// Returns an error for malformed, unsupported or unbounded declarations. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DESCRIPTOR_BYTES { + return Err(BaseCompatibilityError::Invalid( + "descriptor too large".into(), + )); + } + let descriptor: Self = serde_json::from_slice(bytes) + .map_err(|e| BaseCompatibilityError::Invalid(e.to_string()))?; + descriptor.validate()?; + Ok(descriptor) + } + + /// Claimed kernel version, in canonical major.minor.patch form. + pub fn kernel_version(&self) -> &str { + &self.kernel_version + } + + /// Guest architecture bound to the selected member pair. + pub fn arch(&self) -> GuestArch { + self.arch + } + + /// Generic workload profile bound to the selected member pair. + pub fn profile(&self) -> WorkloadImageProfile { + self.profile + } + + /// Digest of the exact signed or locally measured image-set manifest. + pub fn image_set_manifest_sha256(&self) -> &Sha256Hex { + &self.image_set_manifest_sha256 + } + + /// Claimed loader paths; a signed descriptor is not a byte-level loader witness. + pub fn libc_loaders(&self) -> &[LibcLoader] { + &self.libc_loaders + } + + /// Claimed kernel capabilities. + pub fn kernel_capabilities(&self) -> &[String] { + &self.kernel_capabilities + } + + fn validate(&self) -> Result<(), BaseCompatibilityError> { + let invalid = |reason: &str| BaseCompatibilityError::Invalid(reason.into()); + if self.schema != "base-compatibility/v1" { + return Err(invalid("unsupported descriptor schema")); + } + if self.base_image_contract != BaseImageContract::LINUX_LAYER + || self.boot_protocol != BootProtocol::LinuxDirect + || self.filesystem_abi != 1 + { + return Err(invalid("unsupported base, boot or filesystem ABI")); + } + if self.kernel.name == self.composition_tar.name + || self.kernel.size == 0 + || self.composition_tar.size == 0 + { + return Err(invalid("invalid artifact pins")); + } + if parse_kernel_version(&self.kernel_version).is_none_or(|version| version[0] == 0) { + return Err(invalid( + "kernel_version must be canonical major.minor.patch", + )); + } + if self.libc_loaders.is_empty() || self.libc_loaders.len() > 2 { + return Err(invalid("libc_loaders must contain one or two entries")); + } + let mut libcs = HashSet::new(); + for loader in &self.libc_loaders { + if loader.libc == GuestLibc::Unknown || !libcs.insert(loader.libc.as_str()) { + return Err(invalid("duplicate or unknown libc loader")); + } + let path = loader.path.as_str(); + if path.len() > 256 + || !path.starts_with('/') + || path.contains("//") + || path.split('/').skip(1).any(|part| { + part.is_empty() + || part == "." + || part == ".." + || !part.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'+') + }) + }) + { + return Err(invalid("invalid absolute libc loader path")); + } + } + if self.kernel_capabilities.len() > KERNEL_CAPABILITIES.len() { + return Err(invalid("too many kernel capabilities")); + } + let mut names = HashSet::new(); + for name in &self.kernel_capabilities { + if !KERNEL_CAPABILITIES.contains(&name.as_str()) || !names.insert(name) { + return Err(invalid("unsupported or duplicate kernel capability")); + } + } + Ok(()) + } + + #[cfg(feature = "host-state")] + fn check_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + let mismatch = BaseCompatibilityError::Mismatch; + if arch != self.arch { + return Err(mismatch("guest architecture")); + } + if requirements.filesystem_abi != self.filesystem_abi { + return Err(mismatch("filesystem ABI")); + } + let minimum = parse_kernel_version(&requirements.minimum_kernel) + .ok_or(mismatch("invalid minimum kernel version"))?; + let base = parse_kernel_version(&self.kernel_version) + .ok_or(mismatch("invalid base kernel version"))?; + if base < minimum { + return Err(mismatch("minimum kernel version")); + } + match requirements.libc.as_str() { + "static" if requirements.loader.is_none() => {} + "musl" | "glibc" => { + let loader = requirements + .loader + .as_deref() + .ok_or(mismatch("libc loader"))?; + if !self + .libc_loaders + .iter() + .any(|entry| entry.libc.as_str() == requirements.libc && entry.path == loader) + { + return Err(mismatch("libc loader")); + } + } + _ => return Err(mismatch("unsupported libc or static loader")), + } + if requirements.required_capabilities.len() > KERNEL_CAPABILITIES.len() { + return Err(mismatch("too many required kernel capabilities")); + } + let mut names = HashSet::new(); + for name in &requirements.required_capabilities { + if !KERNEL_CAPABILITIES.contains(&name.as_str()) + || !names.insert(name) + || !self.kernel_capabilities.contains(name) + { + return Err(mismatch("unsupported or missing kernel capability")); + } + } + Ok(()) + } +} + +fn parse_kernel_version(version: &str) -> Option<[u32; 3]> { + let mut parts = version.split('.'); + let mut parsed = [0; 3]; + for slot in &mut parsed { + let part = parts.next()?; + if part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|c| c.is_ascii_digit()) + { + return None; + } + *slot = part.parse().ok()?; + } + parts.next().is_none().then_some(parsed) +} + +impl LibcLoader { + /// C library this loader serves. + pub fn libc(&self) -> GuestLibc { + self.libc + } + + /// Absolute guest path declared by the descriptor. + pub fn path(&self) -> &str { + &self.path + } +} + +/// Why a descriptor cannot authorize the selected base. +#[derive(Debug, Error)] +pub enum BaseCompatibilityError { + #[error("invalid base compatibility descriptor: {0}")] + Invalid(String), + #[error("base descriptor signature invalid: {0}")] + Signature(String), + #[error("image-set verification failed: {0}")] + ImageSet(#[from] super::ImageSetError), + #[error("image-set revocations unavailable or stale: {0}")] + Revocations(#[from] ImageSetRevocationStoreError), + #[error("descriptor revoked: {0}")] + Revoked(String), + #[error("descriptor and selected image-set members disagree: {0}")] + Mismatch(&'static str), +} + +/// Verify separately signed descriptor bytes and signed, pinned selected image-set files. +/// +/// Loads the applied revocation feed afresh; no supplied or host-claimed authority +/// can replace the lock's release identity or the persisted feed checkpoint. +/// +/// # Errors +/// Refuses any signature, feed, lock, selection, syntax or byte mismatch. +pub fn verify_production_base_compatibility( + bytes: &[u8], + signature_bundle: &[u8], + image_set: &ImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, +) -> Result { + let revocations = load_image_set_revocations()?; + verify_production_with( + bytes, + signature_bundle, + image_set, + expected_arch, + expected_profile, + &revocations, + Checks { + signature: verify_descriptor_signature, + image_set: verify_image_set_artifacts, + }, + ) +} + +type SignatureCheck = fn(&[u8], &[u8], &KeylessTrust) -> Result<(), String>; +type SetCheck = fn( + &ImageSetVerification<'_>, + &[ArtifactName], +) -> Result; + +struct Checks { + signature: SignatureCheck, + image_set: SetCheck, +} + +fn verify_production_with( + bytes: &[u8], + signature_bundle: &[u8], + image_set: &ImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, + revocations: &VerifiedImageSetRevocations, + checks: Checks, +) -> Result { + if signature_bundle.len() > MAX_BUNDLE_BYTES { + return Err(BaseCompatibilityError::Invalid( + "signature bundle too large".into(), + )); + } + let descriptor = BaseCompatibility::parse(bytes)?; + if descriptor.arch != expected_arch || descriptor.profile != expected_profile { + return Err(BaseCompatibilityError::Mismatch("architecture or profile")); + } + // The verifier accepts this trust only from the release lock, never from descriptor JSON. + let trust = image_set.lock_trust(); + (checks.signature)(bytes, signature_bundle, &trust) + .map_err(BaseCompatibilityError::Signature)?; + let selected = [ + descriptor.kernel.name.clone(), + descriptor.composition_tar.name.clone(), + ]; + let checked = (checks.image_set)(&image_set.with_current_revocations(revocations), &selected)?; + let signer = checked.signer_key_id; + if let RevocationStatus::Revoked { reason } = + revocations.status(&signer, &Sha256Hex::from_bytes(bytes)) + { + return Err(BaseCompatibilityError::Revoked(reason)); + } + let (kernel, tar) = bind( + &descriptor, + &checked.manifest, + &checked.manifest_sha256, + &checked.artifacts, + )?; + Ok(ProductionBaseCompatibility { + descriptor, + descriptor_bytes: bytes.to_vec(), + signature_bundle: signature_bundle.to_vec(), + kernel, + composition_tar: tar, + }) +} + +fn verify_descriptor_signature( + bytes: &[u8], + bundle: &[u8], + trust: &KeylessTrust, +) -> Result<(), String> { + let identities: Vec<&str> = trust + .accepted_identities + .iter() + .map(String::as_str) + .collect(); + verify_signed_payload_under_any_identity(bytes, bundle, &identities, &trust.issuer) + .map_err(|e| e.to_string()) +} + +/// Verify unsigned local syntax and exact local image-set member bytes. +/// +/// Never upgrades a locally built set or its descriptor to Production. +/// +/// # Errors +/// Refuses malformed descriptor, stale local image set, or mismatched artifacts. +pub fn verify_development_base_compatibility( + bytes: &[u8], + image_set: &LocalImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, +) -> Result { + let descriptor = BaseCompatibility::parse(bytes)?; + if descriptor.arch != expected_arch || descriptor.profile != expected_profile { + return Err(BaseCompatibilityError::Mismatch("architecture or profile")); + } + let checked = verify_local_image_set(image_set)?; + let (kernel, tar) = bind( + &descriptor, + &checked.manifest, + &checked.manifest_sha256, + &checked.artifacts, + )?; + Ok(DevelopmentBaseCompatibility { + descriptor, + descriptor_bytes: bytes.to_vec(), + kernel, + composition_tar: tar, + }) +} + +fn bind( + descriptor: &BaseCompatibility, + manifest: &ImageSetManifest, + root_sha: &Sha256Hex, + artifacts: &[VerifiedArtifact], +) -> Result<(VerifiedArtifact, VerifiedArtifact), BaseCompatibilityError> { + let mismatch = BaseCompatibilityError::Mismatch; + if &descriptor.image_set_manifest_sha256 != root_sha { + return Err(mismatch("image-set manifest digest")); + } + if manifest.schema_version != super::IMAGE_SET_SCHEMA_VERSION + || manifest.compatibility.base_image_contract != Some(BaseImageContract::LINUX_LAYER) + { + return Err(mismatch("schema 3 Linux base contract required")); + } + let target = MemberTarget::Arch(descriptor.arch); + let find = |role, pin: &ArtifactPin, format: fn(ArtifactFormat) -> bool| { + let member = manifest + .members + .iter() + .find(|member| { + member.role == role + && member.target == target + && member.build_mode.is_none() + && member.artifacts.iter().any(|artifact| { + artifact.name == pin.name + && format(artifact.format) + && artifact.sha256 == pin.sha256 + && artifact.size == pin.size + }) + }) + .ok_or(mismatch("selected member role, format or artifact pin"))?; + if role == ImageSetRole::WorkloadKernel(descriptor.profile) + && member.boot_protocol != Some(BootProtocol::LinuxDirect) + { + return Err(mismatch("kernel boot protocol")); + } + artifacts + .iter() + .find(|artifact| { + artifact.name == pin.name + && artifact.role == role + && artifact.target == target + && artifact.sha256 == pin.sha256 + && artifact.size == pin.size + }) + .cloned() + .ok_or(mismatch("selected artifact bytes")) + }; + Ok(( + find( + ImageSetRole::WorkloadKernel(descriptor.profile), + &descriptor.kernel, + |format| matches!(format, ArtifactFormat::Kernel(_)), + )?, + find( + ImageSetRole::WorkloadRootfs(descriptor.profile), + &descriptor.composition_tar, + |format| format == ArtifactFormat::TarGz, + )?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image_set::MemberArtifact; + use crate::kernel_format::KernelFormat; + + fn fixture() -> ( + BaseCompatibility, + ImageSetManifest, + Sha256Hex, + Vec, + tempfile::TempDir, + ) { + let dir = tempfile::tempdir().expect("tempdir"); + let mut manifest = super::super::tests::manifest(); + manifest.schema_version = super::super::IMAGE_SET_SCHEMA_VERSION; + manifest.mvm_source_commit = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.base_image_contract = Some(BaseImageContract::LINUX_LAYER); + manifest.compatibility.builder_boot_abi = Some(super::super::BuilderBootAbi::NO_MVM_BINARY); + let arch = GuestArch::X86_64; + let profile = WorkloadImageProfile::DefaultTenant; + let target = MemberTarget::Arch(arch); + let kernel_name = ArtifactName::new("kernel.elf").expect("kernel name"); + let tar_name = ArtifactName::new("base.tar.gz").expect("tar name"); + let kernel_bytes = b"kernel bytes"; + let tar_bytes = b"tar bytes"; + std::fs::write(dir.path().join(kernel_name.as_str()), kernel_bytes).expect("kernel"); + std::fs::write(dir.path().join(tar_name.as_str()), tar_bytes).expect("tar"); + let kernel_sha = Sha256Hex::from_bytes(kernel_bytes); + let tar_sha = Sha256Hex::from_bytes(tar_bytes); + let kernel = manifest + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) && member.target == target + }) + .expect("kernel member"); + kernel.artifacts = vec![MemberArtifact { + name: kernel_name.clone(), + format: ArtifactFormat::Kernel(KernelFormat::Elf), + sha256: kernel_sha.clone(), + size: kernel_bytes.len() as u64, + }]; + let rootfs = manifest + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target == target + }) + .expect("rootfs member"); + rootfs.artifacts.push(MemberArtifact { + name: tar_name.clone(), + format: ArtifactFormat::TarGz, + sha256: tar_sha.clone(), + size: tar_bytes.len() as u64, + }); + let root_sha = Sha256Hex::from_bytes(&serde_json::to_vec(&manifest).expect("manifest")); + let bytes = serde_json::to_vec(&serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": root_sha, + "arch": "x86_64", + "profile": "default_tenant", + "kernel": { "name": kernel_name, "sha256": kernel_sha, "size": kernel_bytes.len() }, + "composition_tar": { "name": tar_name, "sha256": tar_sha, "size": tar_bytes.len() }, + "base_image_contract": 1, + "boot_protocol": "linux_direct", + "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock", "virtio_blk"] + })) + .expect("descriptor"); + let descriptor = BaseCompatibility::parse(&bytes).expect("descriptor parses"); + let artifacts = [ + (&descriptor.kernel, ImageSetRole::WorkloadKernel(profile)), + ( + &descriptor.composition_tar, + ImageSetRole::WorkloadRootfs(profile), + ), + ] + .into_iter() + .map(|(pin, role)| VerifiedArtifact { + role, + target, + name: pin.name.clone(), + path: dir.path().join(pin.name.as_str()), + sha256: pin.sha256.clone(), + size: pin.size, + }) + .collect(); + (descriptor, manifest, root_sha, artifacts, dir) + } + + #[test] + fn rejects_unknown_and_malformed_wire_values() { + let (good, _, _, _, _) = fixture(); + let value = serde_json::to_value(serde_json::json!({ + "schema": good.schema, "image_set_manifest_sha256": good.image_set_manifest_sha256, + "arch": good.arch, "profile": good.profile, + "kernel": {"name": good.kernel.name, "sha256": good.kernel.sha256, "size": good.kernel.size}, + "composition_tar": {"name": good.composition_tar.name, "sha256": good.composition_tar.sha256, "size": good.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock"] + })).expect("json"); + for (field, bad) in [ + ("schema", serde_json::json!("base-compatibility/v2")), + ("kernel_version", serde_json::json!("6.12")), + ("kernel_version", serde_json::json!("06.12.3")), + ("kernel_version", serde_json::json!("6.12.3-rc1")), + ("base_image_contract", serde_json::json!(0)), + ("filesystem_abi", serde_json::json!(2)), + ("boot_protocol", serde_json::json!("uefi")), + ( + "libc_loaders", + serde_json::json!([{"libc":"glibc","path":"../ld.so"}]), + ), + ( + "libc_loaders", + serde_json::json!([{"libc":"musl","path":"/lib/ld.so"},{"libc":"musl","path":"/lib/ld2.so"}]), + ), + ("kernel_capabilities", serde_json::json!(["guest_nic"])), + ( + "kernel_capabilities", + serde_json::json!(["virtio_blk", "virtio_blk"]), + ), + ("trust", serde_json::json!("production")), + ] { + let mut candidate = value.clone(); + candidate[field] = bad; + assert!( + BaseCompatibility::parse(&serde_json::to_vec(&candidate).expect("bytes")).is_err(), + "{field}: {candidate}" + ); + } + assert!(BaseCompatibility::parse(&vec![b' '; MAX_DESCRIPTOR_BYTES + 1]).is_err()); + } + + #[test] + fn binding_requires_exact_schema_profile_format_and_artifact_pins() { + let (descriptor, mut manifest, root_sha, artifacts, _dir) = fixture(); + bind(&descriptor, &manifest, &root_sha, &artifacts).expect("valid exact binding"); + assert!( + bind( + &descriptor, + &manifest, + &Sha256Hex::from_bytes(b"other"), + &artifacts + ) + .is_err() + ); + let mut tampered = artifacts.clone(); + tampered[0].sha256 = Sha256Hex::from_bytes(b"tampered"); + assert!(bind(&descriptor, &manifest, &root_sha, &tampered).is_err()); + let tar_index = manifest + .members + .iter() + .position(|member| { + member.role == ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant) + && member.target == MemberTarget::Arch(GuestArch::X86_64) + }) + .expect("rootfs"); + manifest.members[tar_index] + .artifacts + .iter_mut() + .find(|artifact| artifact.name == descriptor.composition_tar.name) + .expect("tar") + .format = ArtifactFormat::Ext4; + assert!(bind(&descriptor, &manifest, &root_sha, &artifacts).is_err()); + manifest.members[tar_index] + .artifacts + .iter_mut() + .find(|artifact| artifact.name == descriptor.composition_tar.name) + .expect("tar") + .format = ArtifactFormat::TarGz; + manifest.schema_version = super::super::LEGACY_IMAGE_SET_SCHEMA_VERSION; + manifest.compatibility.base_image_contract = None; + assert!(bind(&descriptor, &manifest, &root_sha, &artifacts).is_err()); + } + + fn test_set_check( + request: &ImageSetVerification<'_>, + names: &[ArtifactName], + ) -> Result { + super::super::verify::verify_checked_selected(request, names, |_, _, _| Ok(())) + } + + fn test_signature(bytes: &[u8], bundle: &[u8], _: &KeylessTrust) -> Result<(), String> { + if bundle == Sha256Hex::from_bytes(bytes).as_str().as_bytes() { + Ok(()) + } else { + Err("signature does not bind descriptor bytes".into()) + } + } + + fn test_feed(revoked: Option) -> VerifiedImageSetRevocations { + use crate::crypto::image_verify::VerifiedSigner; + use crate::pack_revocation::{PACK_REVOCATION_SCHEMA_VERSION, PackRevocationList}; + use crate::pack_trust::RevokedPack; + use crate::release_trust::{RELEASE_OIDC_ISSUER, image_set_revocation_identity}; + use chrono::{TimeZone, Utc}; + + let issued_at = Utc + .with_ymd_and_hms(2026, 11, 1, 0, 0, 0) + .single() + .expect("date"); + let mut list = PackRevocationList { + schema_version: PACK_REVOCATION_SCHEMA_VERSION, + revocations: Vec::new(), + issued_at, + not_after: issued_at + chrono::Duration::days(20), + }; + if let Some(sha) = revoked { + let manifest = super::super::tests::manifest(); + let lock = super::super::tests::lock_for(&manifest); + list.revocations.push(RevokedPack { + key_id: crate::plan::bundle::key_id_from_identity( + &lock.signing_identity.certificate_identity(&lock.repository), + ), + pack_hash: Some(sha), + reason: "withdrawn descriptor".into(), + }); + } + crate::image_set_revocation::verify_image_set_revocations_with( + &serde_json::to_vec(&list).expect("list"), + b"test", + issued_at + chrono::Duration::days(1), + None, + |_, _| { + Ok(VerifiedSigner { + identity: image_set_revocation_identity(1), + issuer: RELEASE_OIDC_ISSUER.into(), + }) + }, + ) + .expect("fresh authenticated test feed") + } + + #[test] + fn production_checks_distinct_signature_selected_bytes_and_revocation() { + let (descriptor, manifest, _, _, dir) = fixture(); + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest"); + let lock = super::super::tests::lock_for(&manifest); + let request = + ImageSetVerification::new(&manifest_bytes, b"set signature", &lock, dir.path()); + let bytes = serde_json::to_vec(&serde_json::json!({ + "schema": descriptor.schema, + "image_set_manifest_sha256": descriptor.image_set_manifest_sha256, + "arch": descriptor.arch, + "profile": descriptor.profile, + "kernel": {"name": descriptor.kernel.name, "sha256": descriptor.kernel.sha256, "size": descriptor.kernel.size}, + "composition_tar": {"name": descriptor.composition_tar.name, "sha256": descriptor.composition_tar.sha256, "size": descriptor.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": descriptor.kernel_version, + "libc_loaders": [{"libc":"musl","path":"/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock", "virtio_blk"] + })) + .expect("descriptor"); + let signature = Sha256Hex::from_bytes(&bytes); + let check = |feed: &VerifiedImageSetRevocations, bytes: &[u8], signature: &[u8]| { + verify_production_with( + bytes, + signature, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + feed, + Checks { + signature: test_signature, + image_set: test_set_check, + }, + ) + }; + let good = test_feed(None); + let held = check(&good, &bytes, signature.as_str().as_bytes()) + .expect("test signer and set accepted"); + assert_eq!(held.descriptor_bytes(), bytes); + assert_eq!(held.signature_bundle(), signature.as_str().as_bytes()); + let checks = || Checks { + signature: test_signature, + image_set: test_set_check, + }; + let fresh = held + .reverify_with(&request, || Ok(test_feed(None)), checks()) + .expect("held evidence reverifies with a current feed"); + assert_eq!(fresh.artifacts(), held.artifacts()); + assert!(matches!( + held.reverify_with( + &request, + || Err(ImageSetRevocationStoreError::Missing), + checks() + ), + Err(BaseCompatibilityError::Revocations( + ImageSetRevocationStoreError::Missing + )) + )); + assert!(matches!( + held.reverify_with( + &request, + || Err(ImageSetRevocationStoreError::Verification( + crate::image_set_revocation::ImageSetRevocationError::Expired { + not_after: chrono::Utc::now() - chrono::Duration::seconds(1), + now: chrono::Utc::now(), + } + )), + checks() + ), + Err(BaseCompatibilityError::Revocations( + ImageSetRevocationStoreError::Verification( + crate::image_set_revocation::ImageSetRevocationError::Expired { .. } + ) + )) + )); + assert!(matches!( + held.reverify_with( + &request, + || Ok(test_feed(Some(Sha256Hex::from_bytes(&bytes)))), + checks() + ), + Err(BaseCompatibilityError::Revoked(_)) + )); + let mut changed_bundle = held.signature_bundle.clone(); + changed_bundle.push(b'!'); + let mut changed_evidence = held; + changed_evidence.signature_bundle = changed_bundle; + assert!(matches!( + changed_evidence.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::Signature(_)) + )); + changed_evidence.signature_bundle = signature.as_str().as_bytes().to_vec(); + changed_evidence.descriptor_bytes.push(b' '); + assert!(matches!( + changed_evidence.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::Signature(_)) + )); + let changed_set = ImageSetVerification::new(b"{}", b"set signature", &lock, dir.path()); + assert!(matches!( + fresh.reverify_with(&changed_set, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!(matches!( + check(&good, &bytes, b"wrong"), + Err(BaseCompatibilityError::Signature(_)) + )); + let mut altered = bytes.clone(); + let version_at = altered + .windows(b"6.12.3".len()) + .position(|window| window == b"6.12.3") + .expect("version in descriptor"); + altered[version_at + 5] = b'4'; + assert!(matches!( + check(&good, &altered, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::Signature(_)) + )); + let revoked = test_feed(Some(Sha256Hex::from_bytes(&bytes))); + assert!(matches!( + check(&revoked, &bytes, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::Revoked(_)) + )); + std::fs::write(dir.path().join("kernel.elf"), b"tampered kernel").expect("tamper"); + assert!(matches!( + fresh.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!(matches!( + check(&good, &bytes, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + } + + #[cfg(feature = "host-state")] + #[test] + fn component_requirements_compare_numeric_kernel_loader_abi_and_capabilities() { + let (mut descriptor, _, _, artifacts, _dir) = fixture(); + descriptor.kernel_version = "6.10.0".into(); + let verified = ProductionBaseCompatibility { + descriptor, + descriptor_bytes: Vec::new(), + signature_bundle: Vec::new(), + kernel: artifacts[0].clone(), + composition_tar: artifacts[1].clone(), + }; + let mut requirements = ComponentRequirements { + filesystem_abi: 1, + libc: "musl".into(), + loader: Some("/lib/ld-musl-x86_64.so.1".into()), + minimum_kernel: "6.9.0".into(), + required_capabilities: vec!["virtio_vsock".into()], + }; + let check = |requirements: &ComponentRequirements, arch| { + verified.check_component_requirements(requirements, arch) + }; + check(&requirements, GuestArch::X86_64).expect("6.10 >= 6.9"); + requirements.libc = "static".into(); + requirements.loader = None; + check(&requirements, GuestArch::X86_64).expect("static requires no loader"); + requirements.loader = Some("/lib/ld-musl-x86_64.so.1".into()); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.libc = "glibc".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.libc = "musl".into(); + requirements.loader = Some("/lib/not-the-loader".into()); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.loader = Some("/lib/ld-musl-x86_64.so.1".into()); + requirements.minimum_kernel = "6.11.0".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.minimum_kernel = "6.10.0-rc1".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.minimum_kernel = "6.9.0".into(); + requirements.required_capabilities = vec!["dm_verity".into()]; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.required_capabilities = vec!["guest_nic".into()]; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.required_capabilities.clear(); + requirements.filesystem_abi = 2; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.filesystem_abi = 1; + assert!(check(&requirements, GuestArch::Aarch64).is_err()); + } + + #[test] + fn unsigned_local_candidate_stays_development_and_checks_measured_bytes() { + let (descriptor, mut manifest, _, _, dir) = fixture(); + let current: super::super::LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "worktree": {"state": "clean"}} + })) + .expect("local checkout identity"); + manifest.producer = super::super::ImageSetProducer::LocalCheckouts(current.clone()); + manifest.revocation_channel = None; + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(GuestArch::X86_64) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant) + | ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant) + ) + }); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + for artifact in &mut member.artifacts { + if artifact.name != descriptor.kernel.name + && artifact.name != descriptor.composition_tar.name + { + let bytes = artifact.name.as_str().as_bytes(); + std::fs::write(dir.path().join(artifact.name.as_str()), bytes) + .expect("local artifact"); + artifact.sha256 = Sha256Hex::from_bytes(bytes); + artifact.size = bytes.len() as u64; + } + } + } + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest"); + let mut descriptor_value = serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": Sha256Hex::from_bytes(&manifest_bytes), + "arch": "x86_64", "profile": "default_tenant", + "kernel": {"name": descriptor.kernel.name, "sha256": descriptor.kernel.sha256, "size": descriptor.kernel.size}, + "composition_tar": {"name": descriptor.composition_tar.name, "sha256": descriptor.composition_tar.sha256, "size": descriptor.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock"] + }); + let bytes = serde_json::to_vec(&descriptor_value).expect("descriptor"); + let request = LocalImageSetVerification::new( + &manifest_bytes, + dir.path(), + ¤t, + GuestArch::X86_64, + ); + let verified = verify_development_base_compatibility( + &bytes, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .expect("unsigned local descriptor remains development"); + assert_eq!(verified.descriptor_bytes(), bytes); + assert_eq!(verified.artifacts().0.name, descriptor.kernel.name); + verified.reverify(&request).expect("unchanged local source"); + let changed_checkout: super::super::LocalCheckouts = + serde_json::from_value(serde_json::json!({ + "images": {"commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worktree": {"state": "clean"}} + })) + .expect("changed checkout"); + let stale_request = LocalImageSetVerification::new( + &manifest_bytes, + dir.path(), + &changed_checkout, + GuestArch::X86_64, + ); + assert!(matches!( + verified.reverify(&stale_request), + Err(BaseCompatibilityError::ImageSet(_)) + )); + descriptor_value["image_set_manifest_sha256"] = + serde_json::json!(Sha256Hex::from_bytes(b"other")); + assert!( + verify_development_base_compatibility( + &serde_json::to_vec(&descriptor_value).expect("changed descriptor"), + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .is_err() + ); + std::fs::write(dir.path().join("base.tar.gz"), b"changed").expect("tamper"); + assert!(matches!( + verified.reverify(&request), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!( + verify_development_base_compatibility( + &bytes, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .is_err() + ); + } +} diff --git a/crates/mvm-core/src/image_set/tests.rs b/crates/mvm-core/src/image_set/tests.rs index 4fb84e6537..4c53df6a23 100644 --- a/crates/mvm-core/src/image_set/tests.rs +++ b/crates/mvm-core/src/image_set/tests.rs @@ -442,7 +442,7 @@ fn manifest_at(set_version: &str) -> ImageSetManifest { } } -fn manifest() -> ImageSetManifest { +pub(super) fn manifest() -> ImageSetManifest { manifest_at("1.0.0") } @@ -557,7 +557,7 @@ fn digest(manifest: &ImageSetManifest) -> Sha256Hex { Sha256Hex::from_bytes(&serde_json::to_vec(manifest).unwrap()) } -fn lock_for(manifest: &ImageSetManifest) -> ImageLock { +pub(super) fn lock_for(manifest: &ImageSetManifest) -> ImageLock { let release_tag = release(manifest).release_tag.clone(); ImageLock { schema_version: IMAGE_LOCK_SCHEMA_VERSION, diff --git a/crates/mvm-core/src/image_set/verify.rs b/crates/mvm-core/src/image_set/verify.rs index be5e1d7ee5..cd7f167916 100644 --- a/crates/mvm-core/src/image_set/verify.rs +++ b/crates/mvm-core/src/image_set/verify.rs @@ -121,6 +121,25 @@ impl<'a> ImageSetVerification<'a> { self.revocations = Some(revocations); self } + + pub(super) fn lock_trust(&self) -> KeylessTrust { + self.lock.keyless_trust() + } + + pub(super) fn with_current_revocations<'b>( + &'b self, + revocations: &'b dyn PackRevocationChecker, + ) -> ImageSetVerification<'b> { + ImageSetVerification { + manifest_bytes: self.manifest_bytes, + signature_bundle: self.signature_bundle, + lock: self.lock, + artifact_dir: self.artifact_dir, + requirement: self.requirement, + host_protocols: self.host_protocols, + revocations: Some(revocations), + } + } } /// What a verification established, for a caller that now wants to use the set. diff --git a/crates/mvm-core/src/plan/bundle.rs b/crates/mvm-core/src/plan/bundle.rs index 3e7955df0f..c2e4681ddd 100644 --- a/crates/mvm-core/src/plan/bundle.rs +++ b/crates/mvm-core/src/plan/bundle.rs @@ -64,8 +64,6 @@ use std::collections::BTreeMap; #[cfg(feature = "host-state")] -use std::collections::BTreeSet; -#[cfg(feature = "host-state")] use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; @@ -83,9 +81,10 @@ pub use mvm_contract::plan::bundle::{ BUNDLE_SCHEMA_VERSION, BundleArtifact, BundleManifest, BundleMember, BundleResources, BundleSecurityPosture, CANONICAL_LINUX_PROOF_MIN_SCHEMA, CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, CanonicalLinuxProof, ENVIRONMENT_MEMBER_MIN_SCHEMA, EmbeddedCliBootAssets, KeyId, - LOCAL_COMPOSITION_MIN_SCHEMA, LocalComposition, LocalCompositionLayer, MANIFEST_FILENAME, - MAX_BUNDLE_ENTRY_BYTES, MAX_BUNDLE_TOTAL_BYTES, MAX_KERNEL_CMDLINE_BYTES, PlanArtifact, - SIGNATURE_FILENAME, VerityInfo, signature_from_base64, signature_to_base64, + LOCAL_COMPOSITION_MIN_SCHEMA, LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, LocalComposition, + LocalCompositionLayer, LocalDevelopmentBaseProof, MANIFEST_FILENAME, MAX_BUNDLE_ENTRY_BYTES, + MAX_BUNDLE_TOTAL_BYTES, MAX_KERNEL_CMDLINE_BYTES, PlanArtifact, SIGNATURE_FILENAME, VerityInfo, + signature_from_base64, signature_to_base64, }; pub use mvm_contract::plan::environment::{ ApplicationService, ENVIRONMENT_DESCRIPTOR_VERSION, EnvironmentCapability, @@ -95,14 +94,17 @@ pub use mvm_contract::plan::environment::{ use crate::arch::GuestArch; use crate::image_set::{ - BackendImageSupport, HostProtocolSupport, ImageSetError, ImageSetManifest, ImageSetRequirement, - ImageSetRole, RetiredGuestRuntimeRole, WorkloadImageProfile, + ArtifactFormat, BackendImageSupport, HostProtocolSupport, ImageSetError, ImageSetManifest, + ImageSetRequirement, ImageSetRole, RetiredGuestRuntimeRole, WorkloadImageProfile, check_workload_protocol_compatibility, require_complete, select_member, select_workload_image, validate_structure, }; use crate::packs::Sha256Hex; mod declarations; +#[cfg(feature = "host-state")] +mod embedded_image_set; +mod local_development_base; mod lookup; #[cfg(feature = "host-state")] mod retention; @@ -114,6 +116,9 @@ pub use declarations::{ unbound_environment_secret_slots, unsupported_environment_requirements, }; #[cfg(feature = "host-state")] +use embedded_image_set::{verify_boot_assets, verify_embedded_image_sets}; +pub use local_development_base::{select_local_development_base, select_local_development_source}; +#[cfg(feature = "host-state")] pub use lookup::existing_local_build_key; pub use lookup::{ BundleResolveError, BundleResolver, FsBundleResolver, FsTrustStore, LocalBundleTrustStore, @@ -752,6 +757,14 @@ pub fn verify_plan_bundle( { return Err(PlanBundleError::CanonicalKernelCustodyRequired); } + if verified + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + { + return Err(PlanBundleError::LocalDevelopmentCustodyRequired); + } Ok(()) }) } @@ -776,6 +789,119 @@ pub fn verify_canonical_plan_bundle( }) } +/// Verify ordinary dev bundles, or a local base with same-host source and kernel custody. +/// Unlike [`verify_canonical_plan_bundle`], this never accepts canonical Linux proof. +#[cfg(feature = "host-state")] +pub fn verify_development_plan_bundle( + pin: &PlanArtifact, + resolver: &dyn BundleResolver, + trust: &dyn TrustStore, + host_key_id: &KeyId, + config: &crate::vm_backend::VmStartConfig, +) -> Result { + verify_plan_bundle_checked(pin, resolver, trust, |verified| { + if verified + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + { + return Err(PlanBundleError::CanonicalKernelCustodyRequired); + } + let Some(proof) = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }) + else { + return Ok(()); + }; + let refused = |reason: &str| PlanBundleError::LocalBaseBinding(reason.into()); + if verified.key_id != *host_key_id + || verified + .manifest + .labels + .get(BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(LOCAL_BUILD_ORIGIN) + || config.canonical_kernel.is_some() + { + return Err(refused( + "local base must be signed on this host without canonical authority", + )); + } + let kernel = verified + .manifest + .find_by_role(&ArtifactRole::Kernel) + .ok_or_else(|| refused("local kernel declaration missing"))?; + if config.kernel_read_sha256.as_deref() != Some(&kernel.sha256) + || config.kernel_path.is_none() + { + return Err(refused("local kernel custody differs from selected base")); + } + let actual = crate::crypto::image_verify::sha256_file(Path::new( + config.kernel_path.as_deref().expect("checked above"), + )) + .map_err(|_| refused("local selected kernel is unreadable"))?; + if actual != kernel.sha256 { + return Err(refused("local selected kernel bytes differ")); + } + verify_local_development_source(proof) + .map_err(|_| refused("local composition source differs from signed evidence"))?; + Ok(()) + }) +} + +#[cfg(feature = "host-state")] +pub fn verify_local_development_source(proof: &LocalDevelopmentBaseProof) -> std::io::Result<()> { + use std::io::Read; + let path = Path::new(&proof.composition_tar_path); + if std::fs::canonicalize(path)? != path { + return Err(std::io::Error::other( + "local composition source path changed", + )); + } + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.len() != proof.composition_tar_size { + return Err(std::io::Error::other("local composition source changed")); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options.open(path)?; + let held = file.metadata()?; + if !held.is_file() || held.len() != proof.composition_tar_size { + return Err(std::io::Error::other("local composition source changed")); + } + let mut digest = Sha256::new(); + let mut bounded = (&mut file).take(proof.composition_tar_size + 1); + let mut bytes = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let count = bounded.read(&mut bytes)?; + if count == 0 { + break; + } + size += count as u64; + digest.update(&bytes[..count]); + } + if size != proof.composition_tar_size + || hex::encode(digest.finalize()) != proof.composition_tar_sha256 + { + return Err(std::io::Error::other( + "local composition source digest changed", + )); + } + Ok(()) +} + /// The canonical owner must have been prepared from this very bundle, and the /// bundle must declare the canonical proof the owner was prepared from. #[cfg(feature = "host-state")] @@ -857,6 +983,10 @@ fn verify_plan_bundle_checked( pub enum PlanBundleError { #[error("canonical kernel binding refused: {0}")] CanonicalKernelBinding(String), + #[error("local development base binding refused: {0}")] + LocalBaseBinding(String), + #[error("local development base requires same-host development admission")] + LocalDevelopmentCustodyRequired, #[error( "canonical Linux proof requires retained backend kernel custody; this admission path does not yet support it" )] @@ -969,6 +1099,8 @@ pub struct VerifiedBootAssets { pub arch: GuestArch, pub image_set: VerifiedEmbeddedImageSet, pub cli_runtime: Option, + /// Local source evidence is never interchangeable with a signed Linux root. + pub local_development_base: Option, } /// The image-set role of the runtime overlay a bundle's boot assets carry. @@ -1044,6 +1176,47 @@ pub fn check_boot_assets_for_backend( ) -> Result<(), ImageSetError> { check_workload_protocol_compatibility(&boot.image_set.manifest.compatibility, host_protocols)?; if boot.cli_runtime.is_some() { + if let Some(proof) = boot.local_development_base.as_ref() { + let mut selected = boot.image_set.manifest.clone(); + let kernel_name = boot.image_set.artifact_paths.keys().next().ok_or( + ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + }, + )?; + let declaration = boot + .image_set + .manifest + .members + .iter() + .flat_map(|member| &member.artifacts) + .find(|artifact| artifact.name.as_str() == kernel_name) + .ok_or(ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + })?; + let (selected_kernel, selected_rootfs) = select_local_development_base( + &boot.image_set.manifest, + boot.arch, + declaration.sha256.as_str(), + declaration.size, + proof, + ) + .map_err(|_| ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + })?; + let profile = match selected_kernel.role { + ImageSetRole::WorkloadKernel(profile) => profile, + _ => unreachable!("local base selection returns a workload kernel"), + }; + selected.members.retain(|member| { + !matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) || member == selected_kernel + || member == selected_rootfs + }); + select_workload_image(&selected, profile, boot.arch, backend)?; + return Ok(()); + } select_member( &boot.image_set.manifest, ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), @@ -1258,197 +1431,23 @@ fn verify_signed_manifest( }); } declarations::validate_declarations(&manifest)?; - - Ok((manifest, declared_key_id)) -} - -/// Bind each embedded image-set declaration to the already verified bundle -/// artifacts. The image-set module owns structure, completeness and backend -/// compatibility; this adapter owns only the cross-manifest name/hash binding. -#[cfg(feature = "host-state")] -fn verify_embedded_image_sets( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, -) -> Result, BundleVerifyError> { - let mut names = BTreeSet::new(); - for artifact in &bundle.artifacts { - if !names.insert(artifact.name.as_str()) { - return Err(BundleVerifyError::DuplicateArtifactName { - name: artifact.name.clone(), - }); - } - } - - bundle - .members - .iter() - .filter_map(|member| match member { - BundleMember::EmbeddedImageSet { manifest_artifact } => Some(manifest_artifact), - BundleMember::KernelCmdline { .. } - | BundleMember::EmbeddedBootAssets { .. } - | BundleMember::EmbeddedCliBootAssets(_) - | BundleMember::CanonicalLinuxProof(_) - | BundleMember::LocalComposition(_) - | BundleMember::SecurityPosture(_) - | BundleMember::BuildProvenance(_) - | BundleMember::Environment(_) => None, - }) - .map(|manifest_artifact| { - verify_embedded_image_set( - bundle, - artifacts, - manifest_artifact, - ImageSetSelection::Full, - ) - }) - .collect() -} - -#[cfg(feature = "host-state")] -fn verify_boot_assets( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, -) -> Result, BundleVerifyError> { - let Some((manifest_artifact, cli_runtime)) = - bundle.members.iter().find_map(|member| match member { - BundleMember::EmbeddedBootAssets { manifest_artifact } => { - Some((manifest_artifact, None)) - } - BundleMember::EmbeddedCliBootAssets(runtime) => { - Some((&runtime.manifest_artifact, Some(runtime))) - } - BundleMember::EmbeddedImageSet { .. } - | BundleMember::CanonicalLinuxProof(_) - | BundleMember::LocalComposition(_) - | BundleMember::KernelCmdline { .. } - | BundleMember::SecurityPosture(_) - | BundleMember::BuildProvenance(_) - | BundleMember::Environment(_) => None, - }) - else { - return Ok(None); - }; - let arch = match bundle.arch.as_str() { - "x86_64" => GuestArch::X86_64, - "aarch64" => GuestArch::Aarch64, - other => { - return Err(BundleVerifyError::ManifestParse(format!( - "unknown boot-assets architecture {other}" - ))); - } - }; - let selection = if cli_runtime.is_some() { - ImageSetSelection::CliKernel(arch) - } else { - ImageSetSelection::LegacyRuntime(arch) - }; - let image_set = verify_embedded_image_set(bundle, artifacts, manifest_artifact, selection)?; - Ok(Some(VerifiedBootAssets { - arch, - image_set, - cli_runtime: cli_runtime.cloned(), - })) -} - -#[derive(Clone, Copy)] -#[cfg(feature = "host-state")] -enum ImageSetSelection { - Full, - LegacyRuntime(GuestArch), - CliKernel(GuestArch), -} - -#[cfg(feature = "host-state")] -fn verify_embedded_image_set( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, - manifest_artifact: &str, - selection: ImageSetSelection, -) -> Result { - let declaration = bundle - .artifacts - .iter() - .find(|artifact| artifact.name == manifest_artifact) - .ok_or_else(|| BundleVerifyError::ImageSetManifestArtifactMissing { - name: manifest_artifact.to_string(), - })?; - let manifest_bytes = artifacts - .read_image_set_manifest(&declaration.path) - .map_err(|reason| BundleVerifyError::ImageSetManifestParse { - name: manifest_artifact.to_string(), - reason, - })?; - let manifest: ImageSetManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { - BundleVerifyError::ImageSetManifestParse { - name: manifest_artifact.to_string(), - reason: error.to_string(), - } - })?; - validate_structure(&manifest).map_err(|reason| BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - })?; - require_complete(&manifest, &ImageSetRequirement::current_train()).map_err(|reason| { - BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - } - })?; - - let selected = match selection { - ImageSetSelection::Full => Ok(manifest.members.iter().collect()), - ImageSetSelection::LegacyRuntime(arch) => { - select_boot_asset_members(&manifest, arch).map(|members| members.to_vec()) - } - ImageSetSelection::CliKernel(arch) => { - select_cli_boot_kernel(&manifest, arch).map(|member| vec![member]) - } - }; - let members = selected.map_err(|reason| BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - })?; - let mut artifact_paths = BTreeMap::new(); - for image_artifact in members - .into_iter() - .flat_map(|member| member.artifacts.iter()) - { - let name = image_artifact.name.as_str(); - let bundle_artifact = bundle - .artifacts - .iter() - .find(|artifact| match selection { - ImageSetSelection::CliKernel(_) => artifact.role == ArtifactRole::Kernel, - ImageSetSelection::Full | ImageSetSelection::LegacyRuntime(_) => { - artifact.name == name - } - }) - .ok_or_else(|| BundleVerifyError::ImageSetArtifactMissing { - name: name.to_string(), - })?; - if image_artifact.sha256.as_str() != bundle_artifact.sha256 { - return Err(BundleVerifyError::ImageSetArtifactDigestMismatch { - name: name.to_string(), - image_set: image_artifact.sha256.as_str().to_string(), - bundle: bundle_artifact.sha256.clone(), - }); - } - if image_artifact.size != bundle_artifact.size_bytes { - return Err(BundleVerifyError::ImageSetArtifactSizeMismatch { - name: name.to_string(), - image_set: image_artifact.size, - bundle: bundle_artifact.size_bytes, - }); + #[cfg(feature = "pr-test-guest-runtime")] + if manifest.members.iter().any(|member| { + matches!(member, BundleMember::EmbeddedCliBootAssets(runtime) if runtime.pr_test_pull_request.is_some()) + }) { + let host_key = existing_local_build_key() + .map_err(|_| BundleVerifyError::ManifestParse("PR test host signer key is unreadable".into()))? + .ok_or_else(|| BundleVerifyError::ManifestParse("PR test host signer key is missing".into()))?; + if bundle_origin(&manifest, &key_id_from_pubkey(&host_key), trust_store) + != BundleOrigin::LocalBuild + { + return Err(BundleVerifyError::ManifestParse( + "PR test runtime requires this host's signed local build".into(), + )); } - artifact_paths.insert(name.to_string(), bundle_artifact.path.clone()); } - Ok(VerifiedEmbeddedImageSet { - manifest_artifact: manifest_artifact.to_string(), - manifest_sha256: Sha256Hex::from_bytes(&manifest_bytes), - manifest, - artifact_paths, - }) + Ok((manifest, declared_key_id)) } /// Convenience: SHA-256 of the entire archive bytes, encoded as @@ -1466,13 +1465,13 @@ mod tests { use chrono::{TimeZone, Utc}; use ed25519_dalek::{Signer, SigningKey}; use rand::Rng; - use std::collections::HashMap; + use std::collections::{BTreeSet, HashMap}; use crate::image_set::{ ArtifactFormat, ArtifactName, BootProtocol, GitCommit, GuestDeviceRequirement, ImageSetCompatibility, ImageSetMember, ImageSetProducer, ImageSetRole, ImageSetVersion, - MemberArtifact, MemberTarget, NixInputs, ProtocolRange, ReleaseProducer, ReleaseTag, - RepositorySlug, RevocationChannel, WorkflowPath, + LocalCheckouts, MemberArtifact, MemberTarget, NixInputs, ProtocolRange, ReleaseProducer, + ReleaseTag, RepoIdentity, RepositorySlug, RevocationChannel, WorkflowPath, WorktreeState, }; use crate::kernel_format::KernelFormat; use crate::packs::{FlakeLockIdentity, SbomReference, SourceRevisionIdentity}; @@ -1680,6 +1679,59 @@ mod tests { MapTrustStore(m) } + #[cfg(not(feature = "pr-test-guest-runtime"))] + #[test] + fn ordinary_bundle_reader_refuses_signed_pr_marker_but_keeps_old_schemas() { + let key = fresh_key(); + let directory = tempfile::tempdir().unwrap(); + let write_signed = |manifest: &BundleManifest, name: &str| { + let bytes = canonical_manifest_bytes(manifest).unwrap(); + let signature = key.sign(&bytes).to_bytes(); + let mut tar = tar::Builder::new(Vec::new()); + append_bytes(&mut tar, MANIFEST_FILENAME, &bytes).unwrap(); + append_bytes(&mut tar, SIGNATURE_FILENAME, &signature).unwrap(); + let path = directory.path().join(name); + std::fs::write(&path, tar.into_inner().unwrap()).unwrap(); + path + }; + let mut manifest = make_manifest(key_id_from_pubkey(&key.verifying_key()), Vec::new()); + for schema in [8, 9] { + manifest.schema_version = schema; + let archive = write_signed(&manifest, &format!("v{schema}.mvmpkg")); + verify_bundle_file(&archive, &trust(&key)) + .expect("older bundle schema without PR marker remains readable"); + } + manifest.schema_version = 10; + manifest.members.push( + serde_json::from_value(serde_json::json!({ + "class": "embedded_cli_boot_assets", + "manifest_artifact": "linux.json", + "version": "1.2.3", + "arch": "aarch64", + "archive_artifact": "runtime.tar.gz", + "checksum_artifact": "runtime.sha256", + "signature_bundle_artifact": "runtime.bundle", + "checksum_signature_bundle_artifact": "runtime.sha256.bundle", + "overlay_artifact": "overlay.ext4", + "overlay_verity_artifact": "overlay.verity", + "initramfs_artifact": "initrd", + "overlay_roothash": "a".repeat(64), + "protocol_version": 2, + "min_supported_protocol_version": 2, + "pr_test_pull_request": 42 + })) + .unwrap(), + ); + let archive = write_signed(&manifest, "pr-test.mvmpkg"); + let error = verify_bundle_file(&archive, &trust(&key)).unwrap_err(); + assert!( + error + .to_string() + .contains("explicitly compiled test-only verifier"), + "ordinary bundle reader must refuse before trusting any artifact: {error}" + ); + } + fn image_member_artifact(name: &str, format: ArtifactFormat) -> (MemberArtifact, Vec) { let bytes = format!("embedded bytes for {name}").into_bytes(); ( @@ -1856,6 +1908,177 @@ mod tests { ) } + #[test] + fn local_base_evidence_selects_only_the_exact_kernel_and_matching_tar_source() { + let (mut set, _) = complete_image_set(); + set.schema_version = crate::image_set::IMAGE_SET_SCHEMA_VERSION; + set.mvm_source_commit = None; + set.revocation_channel = None; + set.compatibility.guest_agent_protocol = None; + set.compatibility.base_image_contract = + Some(crate::image_set::BaseImageContract::LINUX_LAYER); + set.compatibility.builder_boot_abi = Some(crate::image_set::BuilderBootAbi::NO_MVM_BINARY); + let arch = GuestArch::Aarch64; + let profile = WorkloadImageProfile::DefaultTenant; + set.members.retain(|member| { + member.target.admits(arch) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(found) | ImageSetRole::WorkloadRootfs(found) + if found == profile + ) + }); + for member in &mut set.members { + member.pack_hash = None; + member.sbom = None; + } + set.producer = ImageSetProducer::LocalCheckouts(LocalCheckouts { + images: RepoIdentity { + commit: GitCommit::new("a".repeat(40)).unwrap(), + worktree: WorktreeState::Clean, + }, + mvm: None, + }); + let kernel = set + .members + .iter() + .find(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) && member.target.admits(arch) + }) + .unwrap() + .artifacts[0] + .clone(); + let rootfs = set + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target.admits(arch) + }) + .unwrap(); + let tar = MemberArtifact { + name: ArtifactName::new("base-composition.tar.gz").unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(b"exact tar source"), + size: 16, + }; + rootfs.artifacts.push(tar.clone()); + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".into(), + composition_tar_sha256: tar.sha256.as_str().to_string(), + composition_tar_size: tar.size, + composition_tar_path: "/local/base-composition.tar.gz".into(), + }; + let pair = + select_local_development_base(&set, arch, kernel.sha256.as_str(), kernel.size, &proof) + .unwrap(); + assert_eq!(pair.0.artifacts[0], kernel); + assert!(pair.1.artifacts.contains(&tar)); + let (selected_kernel, selected_tar) = + select_local_development_source(&set, arch, profile).unwrap(); + assert_eq!(selected_kernel, &kernel); + assert_eq!(selected_tar, &tar); + let mut missing = set.clone(); + missing + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .retain(|artifact| artifact.format != ArtifactFormat::TarGz); + assert!(select_local_development_source(&missing, arch, profile).is_err()); + let mut duplicate = set.clone(); + let mut second_tar = tar.clone(); + second_tar.name = ArtifactName::new("other.tar.gz").unwrap(); + duplicate + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .push(second_tar); + assert!(select_local_development_source(&duplicate, arch, profile).is_err()); + let mut incomplete = set.clone(); + incomplete + .members + .retain(|member| !matches!(member.role, ImageSetRole::WorkloadKernel(_))); + assert!(select_local_development_source(&incomplete, arch, profile).is_err()); + let mut altered = proof.clone(); + altered.composition_tar_sha256 = "0".repeat(64); + assert!( + select_local_development_base( + &set, + arch, + kernel.sha256.as_str(), + kernel.size, + &altered + ) + .is_err() + ); + assert!(select_local_development_base(&set, arch, "0", kernel.size, &proof).is_err()); + assert!( + select_local_development_base( + &set, + arch, + kernel.sha256.as_str(), + kernel.size + 1, + &proof + ) + .is_err() + ); + let mut relabelled = set.clone(); + relabelled.producer = complete_image_set().0.producer; + assert!( + select_local_development_base( + &relabelled, + arch, + kernel.sha256.as_str(), + kernel.size, + &proof + ) + .is_err() + ); + let rootfs = set + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target.admits(arch) + }) + .unwrap(); + rootfs.build_mode = Some(crate::image_set::MemberBuildMode::Dev); + assert!( + select_local_development_base(&set, arch, kernel.sha256.as_str(), kernel.size, &proof) + .is_err() + ); + } + + #[test] + fn local_base_source_requires_exact_regular_bytes_not_only_a_declared_digest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("source.tar.gz"); + std::fs::write(&path, b"exact tar source").unwrap(); + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".into(), + composition_tar_sha256: sha256_hex(b"exact tar source"), + composition_tar_size: 16, + composition_tar_path: std::fs::canonicalize(&path) + .unwrap() + .to_str() + .unwrap() + .into(), + }; + verify_local_development_source(&proof).unwrap(); + std::fs::write(&path, b"tampered source!").unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + std::fs::remove_file(&path).unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + #[cfg(unix)] + { + std::fs::write(dir.path().join("actual"), b"exact tar source").unwrap(); + std::os::unix::fs::symlink(dir.path().join("actual"), &path).unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + } + } + fn embedded_bundle( sk: &SigningKey, image_set: &ImageSetManifest, @@ -2100,6 +2323,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, })]; let archive = write_bundle(&manifest, &sk, payload).unwrap(); diff --git a/crates/mvm-core/src/plan/bundle/declarations.rs b/crates/mvm-core/src/plan/bundle/declarations.rs index e613fe4785..2e1e25e604 100644 --- a/crates/mvm-core/src/plan/bundle/declarations.rs +++ b/crates/mvm-core/src/plan/bundle/declarations.rs @@ -101,6 +101,12 @@ pub(super) fn validate_declarations(manifest: &BundleManifest) -> Result<(), Bun }) { validate_canonical_linux_proof(proof, manifest)?; } + if let Some(proof) = manifest.members.iter().find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }) { + validate_local_development_base_proof(proof, manifest)?; + } if let Some(composition) = manifest.members.iter().find_map(|member| match member { BundleMember::LocalComposition(composition) => Some(composition), _ => None, @@ -152,6 +158,7 @@ fn declaration_class(member: &BundleMember) -> Option<&'static str> { BundleMember::EmbeddedBootAssets { .. } => Some("embedded_boot_assets"), BundleMember::EmbeddedCliBootAssets(_) => Some("embedded_cli_boot_assets"), BundleMember::CanonicalLinuxProof(_) => Some("canonical_linux_proof"), + BundleMember::LocalDevelopmentBaseProof(_) => Some("local_development_base_proof"), BundleMember::LocalComposition(_) => Some("local_composition"), BundleMember::KernelCmdline { .. } => Some("kernel_cmdline"), BundleMember::SecurityPosture(_) => Some("security_posture"), @@ -167,7 +174,17 @@ fn validate_local_composition( ) -> Result<(), BundleVerifyError> { let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.into()); let proof = manifest.members.iter().find_map(|member| match member { - BundleMember::CanonicalLinuxProof(proof) => Some(proof), + BundleMember::CanonicalLinuxProof(proof) => Some(( + proof.manifest_artifact.as_str(), + vec![ + &proof.signature_bundle_artifact, + &proof.revocation_document_artifact, + &proof.revocation_signature_bundle_artifact, + ], + )), + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some((proof.manifest_artifact.as_str(), Vec::new())) + } _ => None, }); if manifest @@ -179,10 +196,11 @@ fn validate_local_composition( || composition.layers.len() > 64 { return Err(refused( - "local composition requires a verity-sealed local build with canonical Linux proof", + "local composition requires a verity-sealed local build with base proof", )); } - let proof = proof.ok_or_else(|| refused("local composition requires canonical Linux proof"))?; + let (base, proof_artifacts) = + proof.ok_or_else(|| refused("local composition requires base proof"))?; let mut names = std::collections::BTreeSet::new(); let layer_names = composition.layers.iter().flat_map(|layer| { [ @@ -195,17 +213,8 @@ fn validate_local_composition( if !names.insert(name) { return Err(refused("duplicate local composition evidence artifact")); } - if [ - &proof.manifest_artifact, - &proof.signature_bundle_artifact, - &proof.revocation_document_artifact, - &proof.revocation_signature_bundle_artifact, - ] - .contains(&name) - { - return Err(refused( - "composition evidence aliases a canonical Linux proof", - )); + if name == base || proof_artifacts.contains(&name) { + return Err(refused("composition evidence aliases a Linux base proof")); } let artifact = manifest .find_by_name(name) @@ -222,6 +231,75 @@ fn validate_local_composition( Ok(()) } +#[cfg(feature = "host-state")] +fn validate_local_development_base_proof( + proof: &mvm_contract::plan::bundle::LocalDevelopmentBaseProof, + manifest: &BundleManifest, +) -> Result<(), BundleVerifyError> { + let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.into()); + let runtime = manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::EmbeddedCliBootAssets(runtime) => Some(runtime), + _ => None, + }) + .ok_or_else(|| refused("local development base requires explicit CLI boot assets"))?; + let path = std::path::Path::new(&proof.composition_tar_path); + if manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + || manifest + .labels + .get(super::BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(super::LOCAL_BUILD_ORIGIN) + || proof.manifest_artifact != runtime.manifest_artifact + || proof.composition_tar_size == 0 + || proof.composition_tar_size > super::MAX_BUNDLE_ENTRY_BYTES + || proof.composition_tar_sha256.len() != 64 + || !proof + .composition_tar_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || proof.composition_tar_path.len() > 4096 + || !path.is_absolute() + || !path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + || !manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalComposition(_))) + { + return Err(refused( + "invalid local development base proof or mixed authority", + )); + } + let artifact = manifest + .find_by_name(&proof.manifest_artifact) + .ok_or_else(|| refused("local development base manifest artifact missing"))?; + if artifact.role != ArtifactRole::Other + || artifact.size_bytes == 0 + || artifact.size_bytes > 4 * 1024 * 1024 + || manifest + .artifacts + .iter() + .filter(|item| item.role == ArtifactRole::Rootfs && item.size_bytes > 0) + .count() + != 1 + { + return Err(refused( + "local development base has invalid manifest or rootfs", + )); + } + Ok(()) +} + #[cfg(feature = "host-state")] fn validate_canonical_linux_proof( proof: &mvm_contract::plan::bundle::CanonicalLinuxProof, @@ -293,6 +371,36 @@ fn validate_cli_runtime( manifest: &BundleManifest, ) -> Result<(), BundleVerifyError> { let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + if let Some(pull_request) = runtime.pr_test_pull_request { + #[cfg(not(feature = "pr-test-guest-runtime"))] + let _ = pull_request; + #[cfg(not(feature = "pr-test-guest-runtime"))] + return Err(refused( + "PR test runtime requires an explicitly compiled test-only verifier", + )); + #[cfg(feature = "pr-test-guest-runtime")] + if pull_request == 0 + || manifest.profile.as_deref() != Some("dev") + || manifest.security_posture().map(|posture| posture.profile) != Some(AgentProfile::Dev) + || manifest + .labels + .get(super::BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(super::LOCAL_BUILD_ORIGIN) + || !manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + || manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + { + return Err(refused( + "PR test runtime requires a host-local Dev base without release or canonical authority", + )); + } + } if runtime.arch != manifest.arch || !matches!(runtime.arch.as_str(), "x86_64" | "aarch64") || runtime.version.is_empty() @@ -674,6 +782,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let proof = CanonicalLinuxProof { manifest_artifact: "linux.json".into(), @@ -820,6 +929,212 @@ mod tests { manifest } + #[test] + fn local_base_requires_schema_nine_sealed_output_and_no_canonical_escalation() { + use mvm_contract::plan::bundle::{ + CanonicalLinuxProof, EmbeddedCliBootAssets, LocalComposition, LocalDevelopmentBaseProof, + }; + let runtime = EmbeddedCliBootAssets { + manifest_artifact: "linux.json".into(), + version: "0.27.0".into(), + arch: "x86_64".into(), + archive_artifact: "runtime.tar.gz".into(), + checksum_artifact: "runtime.sha256".into(), + signature_bundle_artifact: "runtime.bundle".into(), + checksum_signature_bundle_artifact: "runtime.sha256.bundle".into(), + overlay_artifact: "overlay".into(), + overlay_verity_artifact: "overlay.verity".into(), + initramfs_artifact: "initrd".into(), + overlay_roothash: "a".repeat(64), + protocol_version: 2, + min_supported_protocol_version: 2, + pr_test_pull_request: None, + }; + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "linux.json".into(), + composition_tar_sha256: "a".repeat(64), + composition_tar_size: 16, + composition_tar_path: "/local/base.tar.gz".into(), + }; + let names = [ + "linux.json", + "runtime.tar.gz", + "runtime.sha256", + "runtime.bundle", + "runtime.sha256.bundle", + "overlay", + "overlay.verity", + "initrd", + "kernel", + "rootfs.ext4", + "composition-lock.json", + ]; + let artifacts = names + .into_iter() + .map(|name| { + artifact( + name, + match name { + "initrd" => ArtifactRole::Initrd, + "kernel" => ArtifactRole::Kernel, + "rootfs.ext4" => ArtifactRole::Rootfs, + _ => ArtifactRole::Other, + }, + &"a".repeat(64), + 16, + ) + }) + .collect(); + let mut valid = with_verity(manifest( + artifacts, + vec![ + BundleMember::EmbeddedCliBootAssets(runtime), + BundleMember::LocalDevelopmentBaseProof(proof), + BundleMember::LocalComposition(LocalComposition { + lock_artifact: "composition-lock.json".into(), + layers: Vec::new(), + }), + ], + )); + valid.labels.insert( + super::super::BUNDLE_ORIGIN_LABEL.into(), + super::super::LOCAL_BUILD_ORIGIN.into(), + ); + validate_declarations(&valid).unwrap(); + let mut pr_test = valid.clone(); + let BundleMember::EmbeddedCliBootAssets(runtime) = &mut pr_test.members[0] else { + unreachable!("test fixture begins with CLI runtime") + }; + runtime.pr_test_pull_request = Some(42); + pr_test.schema_version = 9; + assert!(validate_declarations(&pr_test).is_err()); + pr_test.schema_version = 8; + assert!(validate_declarations(&pr_test).is_err()); + pr_test.schema_version = 10; + #[cfg(not(feature = "pr-test-guest-runtime"))] + assert!(validate_declarations(&pr_test).is_err()); + #[cfg(feature = "pr-test-guest-runtime")] + { + assert!( + validate_declarations(&pr_test).is_err(), + "missing Dev posture must fail" + ); + pr_test.profile = Some("dev".into()); + pr_test + .members + .push(BundleMember::SecurityPosture(BundleSecurityPosture { + profile: AgentProfile::Dev, + ..sealed() + })); + validate_declarations(&pr_test).unwrap(); + { + use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; + struct HostKey(VerifyingKey); + impl super::super::TrustStore for HostKey { + fn lookup(&self, _: &KeyId) -> Option { + Some(self.0) + } + } + let home = tempfile::tempdir().unwrap(); + let mut env = crate::util::test_env::TestEnv::new(); + env.isolate_mvm_home(home.path()); + let host = SigningKey::from_bytes(&[42; 32]); + let other = SigningKey::from_bytes(&[43; 32]); + let keys = crate::config::mvm_keys_dir(); + std::fs::create_dir_all(&keys).unwrap(); + std::fs::write( + keys.join("host-signer.pub"), + host.verifying_key().as_bytes(), + ) + .unwrap(); + let mut signed = pr_test.clone(); + signed.key_id = super::super::key_id_from_pubkey(&host.verifying_key()); + let bytes = super::super::canonical_manifest_bytes(&signed).unwrap(); + let signature = host.sign(&bytes).to_bytes(); + super::super::verify_signed_manifest( + &bytes, + &signature, + &HostKey(host.verifying_key()), + ) + .expect("this host's signed local Dev bundle may carry the PR test marker"); + std::fs::write( + keys.join("host-signer.pub"), + other.verifying_key().as_bytes(), + ) + .unwrap(); + assert!( + super::super::verify_signed_manifest( + &bytes, + &signature, + &HostKey(host.verifying_key()), + ) + .is_err(), + "a different host's key cannot satisfy local PR test provenance" + ); + } + let mut not_local = pr_test.clone(); + not_local.labels.clear(); + assert!(validate_declarations(¬_local).is_err()); + let mut missing_base = pr_test.clone(); + missing_base + .members + .retain(|member| !matches!(member, BundleMember::LocalDevelopmentBaseProof(_))); + assert!(validate_declarations(&missing_base).is_err()); + let mut zero = pr_test.clone(); + let BundleMember::EmbeddedCliBootAssets(runtime) = &mut zero.members[0] else { + unreachable!("test fixture begins with CLI runtime") + }; + runtime.pr_test_pull_request = Some(0); + assert!(validate_declarations(&zero).is_err()); + let mut prod = pr_test.clone(); + prod.profile = Some("prod".into()); + assert!(validate_declarations(&prod).is_err()); + let mut canonical = pr_test.clone(); + canonical + .members + .push(BundleMember::CanonicalLinuxProof(CanonicalLinuxProof { + manifest_artifact: "linux.json".into(), + signature_bundle_artifact: "linux.bundle".into(), + revocation_document_artifact: "revocations.json".into(), + revocation_signature_bundle_artifact: "revocations.bundle".into(), + })); + assert!(validate_declarations(&canonical).is_err()); + } + let mut bad = valid.clone(); + bad.schema_version = 8; + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.labels.clear(); + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.members + .push(BundleMember::CanonicalLinuxProof(CanonicalLinuxProof { + manifest_artifact: "linux.json".into(), + signature_bundle_artifact: "linux.bundle".into(), + revocation_document_artifact: "revocations.json".into(), + revocation_signature_bundle_artifact: "revocations.bundle".into(), + })); + assert!(validate_declarations(&bad).is_err()); + for path in ["relative/base.tar.gz", "/local/../base.tar.gz"] { + let mut bad = valid.clone(); + let BundleMember::LocalDevelopmentBaseProof(proof) = &mut bad.members[1] else { + unreachable!() + }; + proof.composition_tar_path = path.into(); + assert!(validate_declarations(&bad).is_err(), "{path}"); + } + let mut bad = valid.clone(); + let BundleMember::LocalDevelopmentBaseProof(proof) = &mut bad.members[1] else { + unreachable!() + }; + proof.composition_tar_sha256 = "G".repeat(64); + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.artifacts + .retain(|artifact| artifact.role != ArtifactRole::Rootfs); + assert!(validate_declarations(&bad).is_err()); + } + #[test] fn budget_refuses_an_entry_over_two_gib_before_counting_it() { let mut budget = BundleSizeBudget::default(); diff --git a/crates/mvm-core/src/plan/bundle/embedded_image_set.rs b/crates/mvm-core/src/plan/bundle/embedded_image_set.rs new file mode 100644 index 0000000000..4341c456e0 --- /dev/null +++ b/crates/mvm-core/src/plan/bundle/embedded_image_set.rs @@ -0,0 +1,221 @@ +//! Bind embedded image-set declarations to verified bundle artifacts. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ + ArtifactRole, BundleManifest, BundleMember, BundleVerifyError, GuestArch, ImageSetManifest, + ImageSetRequirement, LocalDevelopmentBaseProof, Sha256Hex, VerifiedBootAssets, + VerifiedEmbeddedImageSet, require_complete, select_boot_asset_members, select_cli_boot_kernel, + select_local_development_base, stream, validate_structure, +}; + +/// Bind each embedded image-set declaration to the already verified bundle +/// artifacts. The image-set module owns structure, completeness and backend +/// compatibility; this adapter owns only the cross-manifest name/hash binding. +pub(super) fn verify_embedded_image_sets( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, +) -> Result, BundleVerifyError> { + let mut names = BTreeSet::new(); + for artifact in &bundle.artifacts { + if !names.insert(artifact.name.as_str()) { + return Err(BundleVerifyError::DuplicateArtifactName { + name: artifact.name.clone(), + }); + } + } + + bundle + .members + .iter() + .filter_map(|member| match member { + BundleMember::EmbeddedImageSet { manifest_artifact } => Some(manifest_artifact), + BundleMember::KernelCmdline { .. } + | BundleMember::EmbeddedBootAssets { .. } + | BundleMember::EmbeddedCliBootAssets(_) + | BundleMember::CanonicalLinuxProof(_) + | BundleMember::LocalDevelopmentBaseProof(_) + | BundleMember::LocalComposition(_) + | BundleMember::SecurityPosture(_) + | BundleMember::BuildProvenance(_) + | BundleMember::Environment(_) => None, + }) + .map(|manifest_artifact| { + verify_embedded_image_set( + bundle, + artifacts, + manifest_artifact, + ImageSetSelection::Full, + ) + }) + .collect() +} + +pub(super) fn verify_boot_assets( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, +) -> Result, BundleVerifyError> { + let Some((manifest_artifact, cli_runtime)) = + bundle.members.iter().find_map(|member| match member { + BundleMember::EmbeddedBootAssets { manifest_artifact } => { + Some((manifest_artifact, None)) + } + BundleMember::EmbeddedCliBootAssets(runtime) => { + Some((&runtime.manifest_artifact, Some(runtime))) + } + BundleMember::EmbeddedImageSet { .. } + | BundleMember::CanonicalLinuxProof(_) + | BundleMember::LocalDevelopmentBaseProof(_) + | BundleMember::LocalComposition(_) + | BundleMember::KernelCmdline { .. } + | BundleMember::SecurityPosture(_) + | BundleMember::BuildProvenance(_) + | BundleMember::Environment(_) => None, + }) + else { + return Ok(None); + }; + let arch = match bundle.arch.as_str() { + "x86_64" => GuestArch::X86_64, + "aarch64" => GuestArch::Aarch64, + other => { + return Err(BundleVerifyError::ManifestParse(format!( + "unknown boot-assets architecture {other}" + ))); + } + }; + let local = bundle.members.iter().find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }); + let selection = if let Some(proof) = local { + ImageSetSelection::LocalKernel(arch, proof) + } else if cli_runtime.is_some() { + ImageSetSelection::CliKernel(arch) + } else { + ImageSetSelection::LegacyRuntime(arch) + }; + let image_set = verify_embedded_image_set(bundle, artifacts, manifest_artifact, selection)?; + Ok(Some(VerifiedBootAssets { + arch, + image_set, + cli_runtime: cli_runtime.cloned(), + local_development_base: local.cloned(), + })) +} + +enum ImageSetSelection<'a> { + Full, + LegacyRuntime(GuestArch), + CliKernel(GuestArch), + LocalKernel(GuestArch, &'a LocalDevelopmentBaseProof), +} + +fn verify_embedded_image_set( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, + manifest_artifact: &str, + selection: ImageSetSelection<'_>, +) -> Result { + let declaration = bundle + .artifacts + .iter() + .find(|artifact| artifact.name == manifest_artifact) + .ok_or_else(|| BundleVerifyError::ImageSetManifestArtifactMissing { + name: manifest_artifact.to_string(), + })?; + let manifest_bytes = artifacts + .read_image_set_manifest(&declaration.path) + .map_err(|reason| BundleVerifyError::ImageSetManifestParse { + name: manifest_artifact.to_string(), + reason, + })?; + let manifest: ImageSetManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { + BundleVerifyError::ImageSetManifestParse { + name: manifest_artifact.to_string(), + reason: error.to_string(), + } + })?; + validate_structure(&manifest).map_err(|reason| BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + })?; + if !matches!(&selection, ImageSetSelection::LocalKernel(_, _)) { + require_complete(&manifest, &ImageSetRequirement::current_train()).map_err(|reason| { + BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + } + })?; + } + + let selected = match &selection { + ImageSetSelection::Full => Ok(manifest.members.iter().collect()), + ImageSetSelection::LegacyRuntime(arch) => { + select_boot_asset_members(&manifest, *arch).map(|members| members.to_vec()) + } + ImageSetSelection::CliKernel(arch) => { + select_cli_boot_kernel(&manifest, *arch).map(|member| vec![member]) + } + ImageSetSelection::LocalKernel(arch, proof) => { + let kernel = bundle.find_by_role(&ArtifactRole::Kernel).ok_or_else(|| { + BundleVerifyError::ManifestParse("local kernel artifact missing".into()) + })?; + let (selected, _) = select_local_development_base( + &manifest, + *arch, + &kernel.sha256, + kernel.size_bytes, + proof, + )?; + Ok(vec![selected]) + } + }; + let members = selected.map_err(|reason| BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + })?; + let mut artifact_paths = BTreeMap::new(); + for image_artifact in members + .into_iter() + .flat_map(|member| member.artifacts.iter()) + { + let name = image_artifact.name.as_str(); + let bundle_artifact = bundle + .artifacts + .iter() + .find(|artifact| match &selection { + ImageSetSelection::CliKernel(_) | ImageSetSelection::LocalKernel(_, _) => { + artifact.role == ArtifactRole::Kernel + } + ImageSetSelection::Full | ImageSetSelection::LegacyRuntime(_) => { + artifact.name == name + } + }) + .ok_or_else(|| BundleVerifyError::ImageSetArtifactMissing { + name: name.to_string(), + })?; + if image_artifact.sha256.as_str() != bundle_artifact.sha256 { + return Err(BundleVerifyError::ImageSetArtifactDigestMismatch { + name: name.to_string(), + image_set: image_artifact.sha256.as_str().to_string(), + bundle: bundle_artifact.sha256.clone(), + }); + } + if image_artifact.size != bundle_artifact.size_bytes { + return Err(BundleVerifyError::ImageSetArtifactSizeMismatch { + name: name.to_string(), + image_set: image_artifact.size, + bundle: bundle_artifact.size_bytes, + }); + } + artifact_paths.insert(name.to_string(), bundle_artifact.path.clone()); + } + + Ok(VerifiedEmbeddedImageSet { + manifest_artifact: manifest_artifact.to_string(), + manifest_sha256: Sha256Hex::from_bytes(&manifest_bytes), + manifest, + artifact_paths, + }) +} diff --git a/crates/mvm-core/src/plan/bundle/local_development_base.rs b/crates/mvm-core/src/plan/bundle/local_development_base.rs new file mode 100644 index 0000000000..45d6de02a1 --- /dev/null +++ b/crates/mvm-core/src/plan/bundle/local_development_base.rs @@ -0,0 +1,123 @@ +//! Select the sealed local kernel and tar source bound by development proof. + +use super::{ + ArtifactFormat, BackendImageSupport, BundleVerifyError, GuestArch, ImageSetManifest, + ImageSetRole, LocalDevelopmentBaseProof, WorkloadImageProfile, select_workload_image, + validate_structure, +}; +use crate::image_set::{ImageSetMember, ImageSetProducer, MemberArtifact}; + +fn local_development_pair( + image_set: &ImageSetManifest, + arch: GuestArch, + profile: WorkloadImageProfile, +) -> Result<(&ImageSetMember, &ImageSetMember), BundleVerifyError> { + let refused = || { + BundleVerifyError::ManifestParse( + "local base has no unique compatible sealed kernel/rootfs/tar pair".into(), + ) + }; + if image_set.schema_version != crate::image_set::IMAGE_SET_SCHEMA_VERSION + || !matches!(image_set.producer, ImageSetProducer::LocalCheckouts(_)) + { + return Err(refused()); + } + validate_structure(image_set).map_err(|_| refused())?; + let mut kernels = image_set.members.iter().filter(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) + && member.target.admits(arch) + && member.build_mode.is_none() + }); + let kernel = kernels.next().ok_or_else(refused)?; + if kernels.next().is_some() || kernel.artifacts.len() != 1 { + return Err(refused()); + } + let mut roots = image_set.members.iter().filter(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) + && member.target.admits(arch) + && member.build_mode == kernel.build_mode + }); + let rootfs = roots.next().ok_or_else(refused)?; + if roots.next().is_some() + || rootfs + .artifacts + .iter() + .filter(|artifact| artifact.format == ArtifactFormat::TarGz) + .count() + != 1 + { + return Err(refused()); + } + let mut selected = image_set.clone(); + selected.members.retain(|member| { + !matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) || member == kernel + || member == rootfs + }); + select_workload_image( + &selected, + profile, + arch, + &BackendImageSupport::for_backend(crate::vm_backend::BackendKind::Qemu) + .expect("QEMU supports portable workload boot"), + ) + .map_err(|_| refused())?; + Ok((kernel, rootfs)) +} + +/// Select the same sealed local kernel and tar source the bundle reader accepts. +/// These are declarations, not verified bytes or permission to boot. +pub fn select_local_development_source( + image_set: &ImageSetManifest, + arch: GuestArch, + profile: WorkloadImageProfile, +) -> Result<(&MemberArtifact, &MemberArtifact), BundleVerifyError> { + let (kernel, rootfs) = local_development_pair(image_set, arch, profile)?; + let tar = rootfs + .artifacts + .iter() + .find(|artifact| artifact.format == ArtifactFormat::TarGz) + .expect("unique tar checked by local development pair"); + Ok((&kernel.artifacts[0], tar)) +} + +/// Find exactly one local kernel and same-profile rootfs whose tar matches the proof. +pub fn select_local_development_base<'a>( + image_set: &'a ImageSetManifest, + arch: GuestArch, + kernel_sha256: &str, + kernel_size: u64, + proof: &LocalDevelopmentBaseProof, +) -> Result<(&'a ImageSetMember, &'a ImageSetMember), BundleVerifyError> { + let refused = || { + BundleVerifyError::ManifestParse( + "local base does not bind one matching kernel/rootfs source pair".into(), + ) + }; + let mut pairs = [ + WorkloadImageProfile::DefaultTenant, + WorkloadImageProfile::RootlessTenant, + ] + .into_iter() + .filter_map(|profile| { + let (kernel, rootfs) = local_development_pair(image_set, arch, profile).ok()?; + let selected_kernel = &kernel.artifacts[0]; + let tar = rootfs + .artifacts + .iter() + .find(|artifact| artifact.format == ArtifactFormat::TarGz) + .expect("unique tar checked by local development pair"); + (selected_kernel.sha256.as_str() == kernel_sha256 + && selected_kernel.size == kernel_size + && tar.sha256.as_str() == proof.composition_tar_sha256 + && tar.size == proof.composition_tar_size) + .then_some((kernel, rootfs)) + }); + let pair = pairs.next().ok_or_else(refused)?; + if pairs.next().is_some() { + return Err(refused()); + } + Ok(pair) +} diff --git a/crates/mvm-core/src/plan/bundle/stream.rs b/crates/mvm-core/src/plan/bundle/stream.rs index ec81537ceb..2b3d9b11e5 100644 --- a/crates/mvm-core/src/plan/bundle/stream.rs +++ b/crates/mvm-core/src/plan/bundle/stream.rs @@ -8,6 +8,9 @@ //! Only the embedded image-set manifests, which verification has to parse, //! are read whole, and they are capped at [`MAX_IMAGE_SET_MANIFEST_BYTES`]. +#[cfg(feature = "host-state")] +mod writer; + use std::collections::BTreeMap; #[cfg(feature = "host-state")] use std::collections::BTreeSet; @@ -16,19 +19,18 @@ use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -#[cfg(feature = "host-state")] -use ed25519_dalek::{Signature, Verifier}; use sha2::{Digest, Sha256}; use super::{BundleArtifact, BundleManifest, BundleVerifyError}; #[cfg(feature = "host-state")] use super::{ BundleInstallError, BundleMember, BundleRegistry, BundleSizeBudget, ImageSetSource, - InstalledBundle, KeyId, MANIFEST_FILENAME, ManifestSigner, SIGNATURE_FILENAME, TrustStore, - VerifiedEmbeddedImageSet, canonical_manifest_bytes, declarations, ensure_safe_path, - install_embedded_image_set_cache, key_id_from_pubkey, verify_embedded_image_sets, - verify_signed_manifest, + InstalledBundle, KeyId, MANIFEST_FILENAME, SIGNATURE_FILENAME, TrustStore, + VerifiedEmbeddedImageSet, declarations, ensure_safe_path, install_embedded_image_set_cache, + verify_embedded_image_sets, verify_signed_manifest, }; +#[cfg(feature = "host-state")] +pub use writer::{BundlePayload, write_bundle_to}; /// Read `manifest.json` and `manifest.sig` out of an archive, stopping as soon /// as both are found. A bundle writes them first, so a multi-GiB archive is @@ -227,18 +229,35 @@ enum Destination<'a> { File(&'a Path), } +#[cfg(feature = "host-state")] +#[derive(Clone, Copy)] +enum ArchiveEntries { + Compatible, + Exact, +} + /// Walk an archive once, hashing every declared artifact into the /// destination `route` picks for it and checking it against the manifest. /// Returns the bytes of the artifacts routed to [`Destination::Capture`]. /// -/// Entries the manifest does not declare are skipped, as the in-memory -/// verifier skips them; a declared artifact that appears twice or never is -/// refused. +/// Compatible reads skip undeclared entries like the in-memory verifier; +/// exact reads refuse them. A declared artifact that appears twice or never +/// is always refused. #[cfg(feature = "host-state")] fn stream_artifacts<'p>( archive: impl Read, manifest: &BundleManifest, mut route: impl FnMut(&BundleArtifact) -> Destination<'p>, +) -> Result>, BundleVerifyError> { + stream_artifacts_with_entries(archive, manifest, &mut route, ArchiveEntries::Compatible) +} + +#[cfg(feature = "host-state")] +fn stream_artifacts_with_entries<'p>( + archive: impl Read, + manifest: &BundleManifest, + mut route: impl FnMut(&BundleArtifact) -> Destination<'p>, + entries: ArchiveEntries, ) -> Result>, BundleVerifyError> { let tar_error = |e: io::Error| BundleVerifyError::ManifestParse(format!("tar read: {e}")); let declared: BTreeMap<&str, &BundleArtifact> = manifest @@ -261,7 +280,20 @@ fn stream_artifacts<'p>( .into_owned(); ensure_safe_path(&path)?; budget.admit(&path, entry.size())?; + if matches!(entries, ArchiveEntries::Exact) && !entry.header().entry_type().is_file() { + return Err(BundleVerifyError::ManifestParse(format!( + "non-file archive entry {path}" + ))); + } let Some(artifact) = declared.get(path.as_str()) else { + if matches!(entries, ArchiveEntries::Exact) + && path != MANIFEST_FILENAME + && path != SIGNATURE_FILENAME + { + return Err(BundleVerifyError::ManifestParse(format!( + "unexpected archive entry {path}" + ))); + } continue; }; if entry.size() != artifact.size_bytes { @@ -363,8 +395,145 @@ pub struct VerifiedBundleFile { manifest_signature: [u8; 64], } +/// Selected signed rootfs and verity bytes, owned by a private temporary directory. +/// The paths cease to exist when this value is dropped. +#[derive(Debug)] +#[cfg(feature = "host-state")] +pub struct ExtractedBundleRootfs { + _directory: tempfile::TempDir, + pub rootfs: PathBuf, + pub verity: PathBuf, + pub roothash: String, +} + #[cfg(feature = "host-state")] impl VerifiedBundleFile { + fn verify_archive_pin(&self, mut archive: File) -> Result { + use std::io::{Seek, SeekFrom}; + let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + let metadata = archive + .metadata() + .map_err(|error| invalid(&format!("snapshot metadata: {error}")))?; + if !metadata.is_file() || metadata.len() > super::MAX_BUNDLE_TOTAL_BYTES + 64 * 1024 * 1024 + { + return Err(invalid("snapshot is not a bounded regular archive")); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + let mut digest = HashingWriter::new(io::sink()); + io::copy( + &mut Read::by_ref(&mut archive).take(metadata.len() + 1), + &mut digest, + ) + .map_err(|error| invalid(&format!("snapshot hash: {error}")))?; + let (size, hash) = digest.finish(); + if size != metadata.len() || hash != self.bundle_sha256 { + return Err(invalid( + "retained snapshot differs from its original archive pin", + )); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + let (manifest_bytes, signature) = read_manifest_entries(&mut archive)?; + let signed: BundleManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| invalid(&format!("snapshot manifest: {error}")))?; + if signed != self.manifest || signature.as_slice() != self.manifest_signature { + return Err(invalid("verified manifest differs from the archive pin")); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + Ok(archive) + } + + /// Extract the unique signed rootfs and matching verity sidecar from a pinned archive. + /// + /// The returned paths remain valid while the returned value lives. This does not + /// install the bundle or grant permission to boot without admission. + /// + /// # Errors + /// + /// Refuses missing or ambiguous roles, absent verity, changed archive bytes, + /// invalid entries, or artifact bytes differing from their signed declarations. + pub fn extract_rootfs_with_verity( + &self, + archive: File, + ) -> Result { + let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + let mut paths = BTreeSet::new(); + let mut names = BTreeSet::new(); + if !self + .manifest + .artifacts + .iter() + .all(|artifact| paths.insert(&artifact.path) && names.insert(&artifact.name)) + { + return Err(invalid("signed artifact inventory is ambiguous")); + } + let select = |role| { + let mut matches = self.manifest.artifacts.iter().filter(|a| a.role == role); + let artifact = matches + .next() + .ok_or_else(|| invalid("required bundle role missing"))?; + if matches.next().is_some() + || artifact.size_bytes == 0 + || self + .manifest + .artifacts + .iter() + .filter(|a| a.path == artifact.path || a.name == artifact.name) + .count() + != 1 + { + return Err(invalid("selected signed bundle role is ambiguous")); + } + Ok(artifact) + }; + let rootfs = select(super::ArtifactRole::Rootfs)?; + let verity = self + .manifest + .verity + .as_ref() + .ok_or_else(|| invalid("bundle verity declaration missing"))?; + let sidecar = select(super::ArtifactRole::VerityHashSidecar)?; + if sidecar.name != verity.sidecar_artifact || sidecar.path == rootfs.path { + return Err(invalid( + "bundle verity sidecar does not match the signed role", + )); + } + let archive = self.verify_archive_pin(archive)?; + let directory = tempfile::Builder::new() + .prefix("bundle-rootfs-") + .tempdir() + .map_err(|error| invalid(&format!("private rootfs directory: {error}")))?; + crate::private_fs::create_owned_private_dir(directory.path()) + .map_err(|error| invalid(&format!("private rootfs permissions: {error}")))?; + let rootfs_path = directory.path().join("rootfs.ext4"); + let verity_path = directory.path().join("rootfs.verity"); + stream_artifacts_with_entries( + archive, + &self.manifest, + |artifact| { + if artifact.path == rootfs.path { + Destination::File(&rootfs_path) + } else if artifact.path == sidecar.path { + Destination::File(&verity_path) + } else { + Destination::Discard + } + }, + ArchiveEntries::Exact, + )?; + Ok(ExtractedBundleRootfs { + _directory: directory, + rootfs: rootfs_path, + verity: verity_path, + roothash: verity.roothash.clone(), + }) + } + /// Reopen a bounded member from an archive matching this verified file's /// full SHA-256. This is evidence, not permission to boot without admission. /// @@ -373,11 +542,10 @@ impl VerifiedBundleFile { /// Refuses a changed archive, missing or ambiguous artifact, or invalid bytes. pub fn read_small_verified_member( &self, - mut archive: File, + archive: File, name: &str, maximum: u64, ) -> Result, BundleVerifyError> { - use std::io::{Seek, SeekFrom}; let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); let mut matches = self .manifest @@ -403,38 +571,17 @@ impl VerifiedBundleFile { if member.size_bytes > maximum || member.size_bytes > MAX_IMAGE_SET_MANIFEST_BYTES { return Err(invalid("selected signed member exceeds its capture bound")); } - let metadata = archive - .metadata() - .map_err(|error| invalid(&format!("snapshot metadata: {error}")))?; - if !metadata.is_file() || metadata.len() > super::MAX_BUNDLE_TOTAL_BYTES + 64 * 1024 * 1024 - { - return Err(invalid("snapshot is not a bounded regular archive")); - } - archive - .seek(SeekFrom::Start(0)) - .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; - let mut digest = HashingWriter::new(io::sink()); - io::copy( - &mut Read::by_ref(&mut archive).take(metadata.len() + 1), - &mut digest, - ) - .map_err(|error| invalid(&format!("snapshot hash: {error}")))?; - let (size, hash) = digest.finish(); - if size != metadata.len() || hash != self.bundle_sha256 { - return Err(invalid( - "retained snapshot differs from its original archive pin", - )); - } - archive - .seek(SeekFrom::Start(0)) - .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; - let mut captured = stream_artifacts(archive, &self.manifest, |artifact| { - if artifact.path == member.path { - Destination::Capture - } else { - Destination::Discard - } - })?; + let mut captured = stream_artifacts( + self.verify_archive_pin(archive)?, + &self.manifest, + |artifact| { + if artifact.path == member.path { + Destination::Capture + } else { + Destination::Discard + } + }, + )?; captured .remove(&member.path) .ok_or_else(|| BundleVerifyError::ArtifactMissing { @@ -1244,192 +1391,13 @@ fn stage( Ok(embedded) } -/// The bytes of one artifact being sealed into a bundle. -#[derive(Debug, Clone)] -#[cfg(feature = "host-state")] -pub enum BundlePayload { - /// Bytes already in memory. - Bytes(Vec), - /// A file read as it is written into the archive. - File(PathBuf), -} - -#[cfg(feature = "host-state")] -impl BundlePayload { - fn len(&self) -> io::Result { - match self { - Self::Bytes(bytes) => Ok(bytes.len() as u64), - Self::File(path) => Ok(std::fs::metadata(path)?.len()), - } - } - - fn reader(&self) -> io::Result> { - match self { - Self::Bytes(bytes) => Ok(Box::new(bytes.as_slice())), - Self::File(path) => Ok(Box::new(File::open(path)?)), - } - } -} - -/// A reader that hashes and counts what is read through it. -#[cfg(feature = "host-state")] -struct HashingReader { - inner: R, - hasher: Sha256, - read: u64, -} - -#[cfg(feature = "host-state")] -impl Read for HashingReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let n = self.inner.read(buf)?; - self.hasher.update(&buf[..n]); - self.read += n as u64; - Ok(n) - } -} - -/// Seal `manifest` and the artifacts it declares into a `.mvmpkg` written to -/// `out`, streaming each artifact from its payload. -/// -/// The manifest is validated and signed before a byte is written, and the -/// signature is checked against the signer's own key. Each artifact is hashed -/// as it is copied into the archive and compared with its declaration, so a -/// file that changed between hashing and sealing fails the write rather than -/// producing a bundle that installs nowhere. A caller writing to a file -/// should write to a temporary path and rename it on success. -#[cfg(feature = "host-state")] -pub fn write_bundle_to( - manifest: &BundleManifest, - signer: &dyn ManifestSigner, - mut artifacts: Vec<(String, BundlePayload)>, - out: impl Write, -) -> Result<()> { - let verifying_key = signer.verifying_key(); - let derived = key_id_from_pubkey(&verifying_key); - declarations::validate_declarations(manifest).context("refusing to seal the bundle")?; - anyhow::ensure!( - manifest.key_id == derived, - "manifest key_id ({}) does not match signing key derivation ({})", - manifest.key_id.0, - derived.0 - ); - let mut declared = Vec::with_capacity(artifacts.len()); - for (path, payload) in &artifacts { - ensure_safe_path(path).context("artifact path validation")?; - let artifact = manifest - .artifacts - .iter() - .find(|a| a.path == *path) - .with_context(|| { - format!("archive contains {path:?} not declared in manifest.artifacts") - })?; - let size = payload - .len() - .with_context(|| format!("reading artifact {}", artifact.name))?; - anyhow::ensure!( - artifact.size_bytes == size, - "artifact {} size mismatch at write time: manifest {}, actual {}", - artifact.name, - artifact.size_bytes, - size, - ); - // Bytes in memory can be checked before anything is signed; a file is - // checked as it streams into the archive below. - if let BundlePayload::Bytes(bytes) = payload { - let actual = super::sha256_hex(bytes); - anyhow::ensure!( - artifact.sha256 == actual, - "artifact {} sha256 mismatch at write time: manifest {}, actual {}", - artifact.name, - artifact.sha256, - actual, - ); - } - declared.push(artifact); - } - - let manifest_bytes = canonical_manifest_bytes(manifest)?; - let sig_bytes = signer - .sign_manifest(&manifest_bytes) - .context("signing the bundle manifest")?; - verifying_key - .verify(&manifest_bytes, &Signature::from_bytes(&sig_bytes)) - .map_err(|e| { - anyhow::anyhow!( - "the signer returned a signature that does not verify under its own key {}: {e}", - derived.0 - ) - })?; - - let mut tar = tar::Builder::new(out); - // manifest.json first so a reader finds it — and stops — at the head. - append_entry( - &mut tar, - MANIFEST_FILENAME, - manifest_bytes.len() as u64, - manifest_bytes.as_slice(), - )?; - append_entry( - &mut tar, - SIGNATURE_FILENAME, - sig_bytes.len() as u64, - sig_bytes.as_slice(), - )?; - - // Artifacts in path order — deterministic output. - artifacts.sort_by(|(a, _), (b, _)| a.cmp(b)); - for (path, payload) in &artifacts { - let artifact = declared - .iter() - .find(|a| a.path == *path) - .context("declared artifact disappeared")?; - let mut reader = HashingReader { - inner: payload - .reader() - .with_context(|| format!("opening artifact {}", artifact.name))? - .take(artifact.size_bytes), - hasher: Sha256::new(), - read: 0, - }; - append_entry(&mut tar, path, artifact.size_bytes, &mut reader)?; - let actual = hex::encode(reader.hasher.finalize()); - anyhow::ensure!( - reader.read == artifact.size_bytes && actual == artifact.sha256, - "artifact {} changed while it was being sealed: manifest {} ({} bytes), read {} ({} bytes)", - artifact.name, - artifact.sha256, - artifact.size_bytes, - actual, - reader.read, - ); - } - tar.into_inner() - .and_then(|mut out| out.flush()) - .context("finalise tar archive")?; - Ok(()) -} - -#[cfg(feature = "host-state")] -fn append_entry( - tar: &mut tar::Builder, - path: &str, - size: u64, - data: impl Read, -) -> Result<()> { - let mut header = tar::Header::new_gnu(); - header.set_size(size); - header.set_mode(0o644); - header.set_cksum(); - tar.append_data(&mut header, path, data) - .with_context(|| format!("write tar entry {path:?}")) -} - #[cfg(all(test, feature = "host-state"))] mod tests { + use super::writer::append_entry; use super::*; use crate::plan::bundle::{ - ArtifactRole, BUNDLE_SCHEMA_VERSION, read_and_verify_bundle, sha256_hex, + ArtifactRole, BUNDLE_SCHEMA_VERSION, canonical_manifest_bytes, key_id_from_pubkey, + read_and_verify_bundle, sha256_hex, }; use ed25519_dalek::{SigningKey, VerifyingKey}; @@ -1502,6 +1470,36 @@ mod tests { out } + fn sealed_with_verity(dir: &Path, include_verity: bool) -> PathBuf { + let mut declared = manifest(vec![ + artifact("vmlinux", ArtifactRole::Kernel, b"kernel"), + artifact("rootfs.ext4", ArtifactRole::Rootfs, b"rootfs"), + ]); + let mut payload = vec![ + ("artifacts/vmlinux".to_owned(), b"kernel".to_vec()), + ("artifacts/rootfs.ext4".to_owned(), b"rootfs".to_vec()), + ]; + if include_verity { + declared.artifacts.push(artifact( + "rootfs.verity", + ArtifactRole::VerityHashSidecar, + b"verity", + )); + declared.verity = Some(super::super::VerityInfo { + roothash: "a".repeat(64), + sidecar_artifact: "rootfs.verity".into(), + }); + payload.push(("artifacts/rootfs.verity".to_owned(), b"verity".to_vec())); + } + let path = dir.join("verity.mvmpkg"); + std::fs::write( + &path, + crate::plan::bundle::write_bundle(&declared, &key(), payload).unwrap(), + ) + .unwrap(); + path + } + fn trust() -> OneKey { OneKey(key().verifying_key()) } @@ -1542,6 +1540,119 @@ mod tests { assert_eq!(streamed.bundle_sha256, sha256_hex(&bytes)); } + #[test] + fn verified_rootfs_extraction_owns_only_the_signed_pair() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + let extracted = verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .unwrap(); + assert_eq!(std::fs::read(&extracted.rootfs).unwrap(), b"rootfs"); + assert_eq!(std::fs::read(&extracted.verity).unwrap(), b"verity"); + assert_eq!(extracted.roothash, "a".repeat(64)); + let parent = extracted.rootfs.parent().unwrap().to_owned(); + assert_eq!(std::fs::read_dir(&parent).unwrap().count(), 2); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&parent).unwrap().permissions().mode() & 0o077, + 0 + ); + } + drop(extracted); + assert!(!parent.exists()); + } + + #[test] + fn rootfs_extraction_refuses_changed_archive_and_missing_verity() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + flip(&archive, b"rootfs", b"ROOTFS"); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + + let archive = sealed_with_verity(dir.path(), false); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + } + + #[test] + fn rootfs_extraction_refuses_mutated_verified_manifest() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let mut verified = verify_bundle_file(&archive, &trust()).unwrap(); + verified.manifest.verity.as_mut().unwrap().roothash = "b".repeat(64); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + } + + #[test] + fn rootfs_extraction_refuses_unexpected_and_symlink_archive_entries() { + let dir = tempfile::tempdir().unwrap(); + let mut declared = manifest(vec![ + artifact("vmlinux", ArtifactRole::Kernel, b"k"), + artifact("rootfs.ext4", ArtifactRole::Rootfs, b"r"), + artifact("rootfs.verity", ArtifactRole::VerityHashSidecar, b"v"), + ]); + declared.verity = Some(super::super::VerityInfo { + roothash: "a".repeat(64), + sidecar_artifact: "rootfs.verity".into(), + }); + let manifest_bytes = canonical_manifest_bytes(&declared).unwrap(); + let signature = ed25519_dalek::Signer::sign(&key(), &manifest_bytes).to_bytes(); + for symlink in [false, true] { + let mut tar = tar::Builder::new(Vec::new()); + append_entry( + &mut tar, + MANIFEST_FILENAME, + manifest_bytes.len() as u64, + manifest_bytes.as_slice(), + ) + .unwrap(); + append_entry(&mut tar, SIGNATURE_FILENAME, 64, signature.as_slice()).unwrap(); + for (name, bytes) in [ + ("vmlinux", b"k".as_slice()), + ("rootfs.ext4", b"r"), + ("rootfs.verity", b"v"), + ] { + append_entry(&mut tar, &format!("artifacts/{name}"), 1, bytes).unwrap(); + } + if symlink { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_link_name("rootfs.ext4").unwrap(); + header.set_cksum(); + tar.append_data(&mut header, "artifacts/link", io::empty()) + .unwrap(); + } else { + append_entry(&mut tar, "artifacts/unexpected", 1, b"x".as_slice()).unwrap(); + } + let path = dir.path().join("extra.mvmpkg"); + std::fs::write(&path, tar.into_inner().unwrap()).unwrap(); + let verified = verify_bundle_file(&path, &trust()).unwrap(); + assert!( + verified + .extract_rootfs_with_verity(File::open(&path).unwrap()) + .is_err(), + "unexpected entry accepted: symlink={symlink}" + ); + } + } + fn flip(path: &Path, needle: &[u8], replacement: &[u8]) { let mut bytes = std::fs::read(path).unwrap(); let at = bytes diff --git a/crates/mvm-core/src/plan/bundle/stream/writer.rs b/crates/mvm-core/src/plan/bundle/stream/writer.rs new file mode 100644 index 0000000000..bb892c935a --- /dev/null +++ b/crates/mvm-core/src/plan/bundle/stream/writer.rs @@ -0,0 +1,189 @@ +//! Stream artifact payloads into a signed bundle archive. + +use std::fs::File; +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use ed25519_dalek::{Signature, Verifier}; +use sha2::{Digest, Sha256}; + +use super::super::{ + BundleManifest, MANIFEST_FILENAME, ManifestSigner, SIGNATURE_FILENAME, + canonical_manifest_bytes, declarations, ensure_safe_path, key_id_from_pubkey, +}; + +/// The bytes of one artifact being sealed into a bundle. +#[derive(Debug, Clone)] +pub enum BundlePayload { + /// Bytes already in memory. + Bytes(Vec), + /// A file read as it is written into the archive. + File(PathBuf), +} + +impl BundlePayload { + fn len(&self) -> io::Result { + match self { + Self::Bytes(bytes) => Ok(bytes.len() as u64), + Self::File(path) => Ok(std::fs::metadata(path)?.len()), + } + } + + fn reader(&self) -> io::Result> { + match self { + Self::Bytes(bytes) => Ok(Box::new(bytes.as_slice())), + Self::File(path) => Ok(Box::new(File::open(path)?)), + } + } +} + +/// A reader that hashes and counts what is read through it. +struct HashingReader { + inner: R, + hasher: Sha256, + read: u64, +} + +impl Read for HashingReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = self.inner.read(buf)?; + self.hasher.update(&buf[..n]); + self.read += n as u64; + Ok(n) + } +} + +/// Seal `manifest` and the artifacts it declares into a `.mvmpkg` written to +/// `out`, streaming each artifact from its payload. +/// +/// The manifest is validated and signed before a byte is written, and the +/// signature is checked against the signer's own key. Each artifact is hashed +/// as it is copied into the archive and compared with its declaration, so a +/// file that changed between hashing and sealing fails the write rather than +/// producing a bundle that installs nowhere. A caller writing to a file +/// should write to a temporary path and rename it on success. +pub fn write_bundle_to( + manifest: &BundleManifest, + signer: &dyn ManifestSigner, + mut artifacts: Vec<(String, BundlePayload)>, + out: impl Write, +) -> Result<()> { + let verifying_key = signer.verifying_key(); + let derived = key_id_from_pubkey(&verifying_key); + declarations::validate_declarations(manifest).context("refusing to seal the bundle")?; + anyhow::ensure!( + manifest.key_id == derived, + "manifest key_id ({}) does not match signing key derivation ({})", + manifest.key_id.0, + derived.0 + ); + let mut declared = Vec::with_capacity(artifacts.len()); + for (path, payload) in &artifacts { + ensure_safe_path(path).context("artifact path validation")?; + let artifact = manifest + .artifacts + .iter() + .find(|a| a.path == *path) + .with_context(|| { + format!("archive contains {path:?} not declared in manifest.artifacts") + })?; + let size = payload + .len() + .with_context(|| format!("reading artifact {}", artifact.name))?; + anyhow::ensure!( + artifact.size_bytes == size, + "artifact {} size mismatch at write time: manifest {}, actual {}", + artifact.name, + artifact.size_bytes, + size, + ); + // Bytes in memory can be checked before anything is signed; a file is + // checked as it streams into the archive below. + if let BundlePayload::Bytes(bytes) = payload { + let actual = super::super::sha256_hex(bytes); + anyhow::ensure!( + artifact.sha256 == actual, + "artifact {} sha256 mismatch at write time: manifest {}, actual {}", + artifact.name, + artifact.sha256, + actual, + ); + } + declared.push(artifact); + } + + let manifest_bytes = canonical_manifest_bytes(manifest)?; + let sig_bytes = signer + .sign_manifest(&manifest_bytes) + .context("signing the bundle manifest")?; + verifying_key + .verify(&manifest_bytes, &Signature::from_bytes(&sig_bytes)) + .map_err(|e| { + anyhow::anyhow!( + "the signer returned a signature that does not verify under its own key {}: {e}", + derived.0 + ) + })?; + + let mut tar = tar::Builder::new(out); + // manifest.json first so a reader finds it — and stops — at the head. + append_entry( + &mut tar, + MANIFEST_FILENAME, + manifest_bytes.len() as u64, + manifest_bytes.as_slice(), + )?; + append_entry( + &mut tar, + SIGNATURE_FILENAME, + sig_bytes.len() as u64, + sig_bytes.as_slice(), + )?; + + // Artifacts in path order — deterministic output. + artifacts.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (path, payload) in &artifacts { + let artifact = declared + .iter() + .find(|a| a.path == *path) + .context("declared artifact disappeared")?; + let mut reader = HashingReader { + inner: payload + .reader() + .with_context(|| format!("opening artifact {}", artifact.name))? + .take(artifact.size_bytes), + hasher: Sha256::new(), + read: 0, + }; + append_entry(&mut tar, path, artifact.size_bytes, &mut reader)?; + let actual = hex::encode(reader.hasher.finalize()); + anyhow::ensure!( + reader.read == artifact.size_bytes && actual == artifact.sha256, + "artifact {} changed while it was being sealed: manifest {} ({} bytes), read {} ({} bytes)", + artifact.name, + artifact.sha256, + artifact.size_bytes, + actual, + reader.read, + ); + } + tar.into_inner() + .and_then(|mut out| out.flush()) + .context("finalise tar archive")?; + Ok(()) +} + +pub(super) fn append_entry( + tar: &mut tar::Builder, + path: &str, + size: u64, + data: impl Read, +) -> Result<()> { + let mut header = tar::Header::new_gnu(); + header.set_size(size); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, path, data) + .with_context(|| format!("write tar entry {path:?}")) +} diff --git a/crates/mvm-core/src/protocol/runtime_release.rs b/crates/mvm-core/src/protocol/runtime_release.rs index c254a1f1f9..ee4f72af78 100644 --- a/crates/mvm-core/src/protocol/runtime_release.rs +++ b/crates/mvm-core/src/protocol/runtime_release.rs @@ -17,6 +17,16 @@ pub struct RuntimeReleaseProof { pub arch: GuestArch, } +/// Untrusted PR-workflow runtime input. A release proof cannot stand in for it. +#[cfg(feature = "pr-test-guest-runtime")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct PrTestRuntimeProof { + pub input: RuntimeReleaseProof, + pub pull_request: u64, +} + /// Untrusted record of the artifacts selected for a launch. A checkpoint must /// authenticate this record and reverify its release and artifact bytes before /// using it; reading a metadata file does not create runtime authority. @@ -67,6 +77,10 @@ pub enum RuntimeBindingProvenance { arch: GuestArch, archive_sha256: String, }, + #[cfg(feature = "pr-test-guest-runtime")] + PrTest { + proof: PrTestRuntimeProof, + }, } impl RuntimeLaunchBinding { @@ -97,6 +111,10 @@ impl RuntimeLaunchBinding { config.legacy_portable_runtime = Some(proof.clone()); config.legacy_bundle_runtime = None; config.runtime_release_proof = None; + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = None; + } config.source_runtime_archive_sha256 = None; config.unsigned_runtime_development = false; config.bundle_boot_assets = None; @@ -118,6 +136,10 @@ impl RuntimeLaunchBinding { config.runtime_overlay_verity_path = None; config.runtime_overlay_roothash = None; config.runtime_release_proof = None; + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = None; + } config.unsigned_runtime_development = false; config.source_runtime_archive_sha256 = None; } @@ -138,10 +160,25 @@ impl CliRuntimeLaunchBinding { config.initrd_path = Some(self.initramfs_path.display().to_string()); config.runtime_overlay_roothash = Some(self.overlay_roothash.clone()); config.runtime_release_proof = self.release_proof().cloned(); - config.unsigned_runtime_development = self.release_proof().is_none(); + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = match &self.provenance { + RuntimeBindingProvenance::PrTest { proof } => Some(proof.clone()), + RuntimeBindingProvenance::Release { .. } + | RuntimeBindingProvenance::Source { .. } => None, + }; + } + config.unsigned_runtime_development = match &self.provenance { + RuntimeBindingProvenance::Source { .. } => true, + RuntimeBindingProvenance::Release { .. } => false, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => false, + }; config.source_runtime_archive_sha256 = match &self.provenance { RuntimeBindingProvenance::Release { .. } => None, RuntimeBindingProvenance::Source { archive_sha256, .. } => Some(archive_sha256.clone()), + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => None, }; } @@ -149,6 +186,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => Some(proof), RuntimeBindingProvenance::Source { .. } => None, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => None, } } @@ -156,6 +195,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => &proof.version, RuntimeBindingProvenance::Source { version, .. } => version, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => &proof.input.version, } } @@ -163,6 +204,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => proof.arch, RuntimeBindingProvenance::Source { arch, .. } => *arch, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => proof.input.arch, } } } @@ -234,4 +277,45 @@ mod tests { wrapped.apply_to_config(&mut config); assert_eq!(config.source_runtime_archive_sha256, None); } + + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn pr_test_proof_stays_distinct_from_release_and_rejects_claimed_verification() { + let proof = PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: "/pr/archive".into(), + checksum: "/pr/checksum".into(), + signature_bundle: "/pr/archive.bundle".into(), + archive_sha256: "a".repeat(64), + version: "1.2.3".into(), + arch: GuestArch::X86_64, + }, + pull_request: 42, + }; + let binding = RuntimeLaunchBinding::CliAssembly(CliRuntimeLaunchBinding { + provenance: RuntimeBindingProvenance::PrTest { + proof: proof.clone(), + }, + overlay_path: "/pr/overlay".into(), + overlay_verity_path: "/pr/verity".into(), + initramfs_path: "/pr/initrd".into(), + overlay_roothash: "b".repeat(64), + }); + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!( + serde_json::from_value::(json.clone()).unwrap(), + binding + ); + let mut forged = json.clone(); + forged["provenance"]["proof"]["verified"] = true.into(); + assert!(serde_json::from_value::(forged).is_err()); + let mut impersonated = json; + impersonated["provenance"]["kind"] = "release".into(); + assert!(serde_json::from_value::(impersonated).is_err()); + let mut config = super::super::vm_backend::VmStartConfig::default(); + binding.apply_to_config(&mut config); + assert_eq!(config.pr_test_runtime_proof, Some(proof)); + assert!(config.runtime_release_proof.is_none()); + assert!(!config.unsigned_runtime_development); + } } diff --git a/crates/mvm-core/src/protocol/vm_backend.rs b/crates/mvm-core/src/protocol/vm_backend.rs index 8d5f641cbe..bcdbae96cc 100644 --- a/crates/mvm-core/src/protocol/vm_backend.rs +++ b/crates/mvm-core/src/protocol/vm_backend.rs @@ -166,6 +166,9 @@ pub struct VmStartConfig { /// Untrusted release inputs. The launch boundary authenticates the archive /// and snapshots the selected boot artifacts before a backend can read them. pub runtime_release_proof: Option, + /// PR-workflow signed test runtime, never release provenance. + #[cfg(feature = "pr-test-guest-runtime")] + pub pr_test_runtime_proof: Option, /// Explicit unsigned contributor runtime. Never sufficient for production /// assurance, regardless of cache fingerprints or declared protocol. pub unsigned_runtime_development: bool, diff --git a/crates/mvm-core/src/registry_pack.rs b/crates/mvm-core/src/registry_pack.rs index 94fa7b4f8f..a241aa1f3c 100644 --- a/crates/mvm-core/src/registry_pack.rs +++ b/crates/mvm-core/src/registry_pack.rs @@ -1045,7 +1045,9 @@ fn validate_registry_pack_manifest( pub mod application; pub mod application_bundle; +pub mod application_component; mod builtin_author_keys; +pub mod component_layer; #[cfg(any(test, feature = "test-support"))] pub use builtin_author_keys::register_test_author_key; pub use builtin_author_keys::{ @@ -1054,8 +1056,9 @@ pub use builtin_author_keys::{ mod dispatch; pub mod runtime_layer; pub use dispatch::{ - AuthenticatedRegistryManifest, VerifiedRegistryApplication, VerifiedRegistryManifest, - VerifiedRegistryRuntimeLayer, adopt_registry_manifest, verify_registry_manifest, + AuthenticatedRegistryManifest, VerifiedRegistryApplication, + VerifiedRegistryApplicationComponent, VerifiedRegistryManifest, VerifiedRegistryRuntimeLayer, + adopt_registry_manifest, verify_registry_manifest, }; pub(crate) fn verify_registry_manifest_with( diff --git a/crates/mvm-core/src/registry_pack/application_component.rs b/crates/mvm-core/src/registry_pack/application_component.rs new file mode 100644 index 0000000000..5834ea7f67 --- /dev/null +++ b/crates/mvm-core/src/registry_pack/application_component.rs @@ -0,0 +1,472 @@ +//! Strict, nonbootable application-component inputs from signed registry metadata. +//! +//! The descriptor authenticates requirements and byte pins, not base suitability +//! or execution authority. Callers must separately admit the selected base and +//! interpret the archive and policy within that admission boundary. + +use std::collections::BTreeSet; +use std::io::Read; +use std::path::Path; + +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +use super::application::{deserialize_object, validate_application_source}; +use super::{ + AuthenticatedRegistryManifest, MAX_REGISTRY_DESCRIPTOR_BYTES, PackPin, PackReference, + RegistryPackFile, RegistryPackPublisherPolicy, RegistryPackSignatureChecker, + RegistryPackVerification, RegistryPackVerificationError, VerifiedRegistryApplicationComponent, +}; +use crate::packs::Sha256Hex; +use crate::plan::bundle::HashingWriter; +use crate::registry_pack_store::RegistryPackStoreError; + +/// The application-component outer manifest version. +pub const APPLICATION_COMPONENT_MANIFEST_SCHEMA_VERSION: u32 = 3; +const MAX_COMPONENT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const GROUP_FILE: &str = "pack/group.toml"; +const PROFILE_FILE: &str = "pack/profile.toml"; + +/// Exact kind discriminator; never a legacy or complete application. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationComponentKind { + ApplicationComponent, +} + +/// Signed, closed outer descriptor with policy files kept separate from the archive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegistryApplicationComponentManifest { + pub schema_version: u32, + pub kind: ApplicationComponentKind, + pub reference: PackReference, + pub description: String, + #[serde(deserialize_with = "deserialize_object")] + pub application_component: ApplicationComponent, + pub files: Vec, +} + +/// One architecture's application archive, requirements and named literal commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationComponent { + pub schema_version: u32, + pub guest_arch: String, + #[serde(deserialize_with = "deserialize_object")] + pub requirements: ComponentRequirements, + #[serde(deserialize_with = "deserialize_object")] + pub archive: ComponentArchive, + pub entrypoints: Vec, +} + +/// Requirements to compare with a selected signed base, not capabilities granted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentRequirements { + pub filesystem_abi: u32, + pub libc: String, + #[serde( + default, + deserialize_with = "present_loader", + skip_serializing_if = "Option::is_none" + )] + pub loader: Option, + pub minimum_kernel: String, + pub required_capabilities: Vec, +} + +fn present_loader<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) +} + +/// External, canonical locator and exact bytes authenticated by the descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentArchive { + pub source: String, + pub archive_sha256: Sha256Hex, + pub archive_size: u64, +} + +/// Named literal argv; only `default` is a candidate for implicit selection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentEntrypoint { + pub name: String, + pub argv: Vec, +} + +fn invalid(reason: &str) -> RegistryPackVerificationError { + RegistryPackVerificationError::ManifestParse(reason.to_string()) +} + +fn canonical_name(name: &str) -> bool { + name.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +fn absolute_path(path: &str) -> bool { + path.starts_with('/') + && path[1..] + .split('/') + .all(|part| !matches!(part, "" | "." | "..")) + && !path.chars().any(|c| c.is_ascii_control() || c == '\\') +} + +fn kernel_version(version: &str) -> bool { + version.split('.').count() == 3 + && version.split('.').all(|part| { + !part.is_empty() + && (part == "0" || !part.starts_with('0')) + && part.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +/// Parse only outer schema 3 / `application_component`, nested schema 1. +/// +/// # Errors +/// +/// Rejects oversized, duplicate, unknown or noncanonical descriptor data. +/// Parsing alone does not authenticate the publisher or any external bytes. +pub fn parse_application_component_manifest( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_REGISTRY_DESCRIPTOR_BYTES { + return Err(invalid("application component descriptor exceeds 1 MiB")); + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let manifest: RegistryApplicationComponentManifest = deserialize_object(&mut deserializer) + .map_err(|_| invalid("application component descriptor has invalid or non-closed JSON"))?; + deserializer + .end() + .map_err(|_| invalid("application component descriptor has trailing data"))?; + let component = &manifest.application_component; + if manifest.schema_version != APPLICATION_COMPONENT_MANIFEST_SCHEMA_VERSION + || component.schema_version != 1 + { + return Err(invalid( + "application component requires outer schema 3 and nested schema 1", + )); + } + let version = manifest.reference.version().map(|v| v.as_str()); + if version.is_none_or(|v| !kernel_version(v)) || manifest.description.trim().is_empty() { + return Err(invalid( + "application component requires an exact numeric version and description", + )); + } + if !matches!(component.guest_arch.as_str(), "aarch64" | "x86_64") { + return Err(invalid( + "application component guest architecture is not canonical", + )); + } + let archive = &component.archive; + if archive.archive_size == 0 || archive.archive_size > MAX_COMPONENT_BYTES { + return Err(invalid( + "application component archive size is out of bounds", + )); + } + validate_application_source(&archive.source)?; + validate_component_requirements(&component.requirements, &component.guest_arch)?; + let mut names = BTreeSet::new(); + if component.entrypoints.len() > 16 + || component.entrypoints.iter().any(|entry| { + !canonical_name(&entry.name) + || !names.insert(&entry.name) + || entry.argv.is_empty() + || entry.argv.len() > 64 + || !absolute_path(&entry.argv[0]) + || entry + .argv + .iter() + .any(|arg| arg.is_empty() || arg.chars().count() > 4096 || arg.contains('\0')) + }) + { + return Err(invalid( + "application component entrypoints are invalid or duplicated", + )); + } + let mut paths = BTreeSet::new(); + if manifest.files.is_empty() + || manifest.files.len() > 2 + || manifest.files.iter().any(|file| { + !matches!(file.path.as_str(), GROUP_FILE | PROFILE_FILE) + || !paths.insert(&file.path) + || file.size == 0 + || file.size > MAX_REGISTRY_DESCRIPTOR_BYTES as u64 + }) + { + return Err(invalid( + "application component requires one or two pinned policy files", + )); + } + Ok(manifest) +} + +pub(super) fn validate_component_requirements( + requirements: &ComponentRequirements, + guest_arch: &str, +) -> Result<(), RegistryPackVerificationError> { + if requirements.filesystem_abi != 1 + || !kernel_version(&requirements.minimum_kernel) + || match requirements.libc.as_str() { + "static" => requirements.loader.is_some(), + "musl" => { + requirements.loader.as_deref() + != Some(format!("/lib/ld-musl-{guest_arch}.so.1").as_str()) + } + "glibc" => requirements + .loader + .as_deref() + .is_none_or(|p| !absolute_path(p)), + _ => true, + } + { + return Err(invalid( + "application component requirements are not canonical", + )); + } + let mut capabilities = BTreeSet::new(); + if requirements.required_capabilities.len() > 16 + || requirements + .required_capabilities + .iter() + .any(|name| !canonical_name(name) || !capabilities.insert(name)) + { + return Err(invalid( + "application component capabilities are invalid or duplicated", + )); + } + Ok(()) +} + +/// Verify the exact lock pin, namespace publisher and current signed revocations. +/// +/// # Errors +/// +/// Refuses missing or drifted pins, untrusted signatures, invalid wire shapes, +/// or a stale, absent or revoking feed. No archive is fetched. +pub fn verify_application_component_manifest( + request: &RegistryPackVerification<'_>, +) -> Result { + let verified = + super::dispatch::verify_component_with(request, super::check_registry_pack_signature)?; + verified.check_current_revocations()?; + Ok(verified) +} + +impl VerifiedRegistryApplicationComponent { + /// Recheck retained signed bytes against today's publisher enrollment and lock pin. + /// + /// # Errors + /// + /// Refuses withdrawn identities or altered descriptor/signature bytes. + pub fn reverify_publisher(&self) -> Result<(), ApplicationComponentInputError> { + let loaded = crate::registry_pack_store::load_publisher_policy_or_official_default( + &crate::config::registry_pack_publisher_policy_path(), + )?; + self.reverify_publisher_with(&loaded.policy, super::check_registry_pack_signature) + } + + fn reverify_publisher_with( + &self, + policy: &RegistryPackPublisherPolicy, + check: RegistryPackSignatureChecker, + ) -> Result<(), ApplicationComponentInputError> { + let pin = PackPin::new(self.reference().clone(), self.manifest_sha256().clone())?; + let lock = super::PackLockfile::new(vec![pin])?; + let request = RegistryPackVerification::new( + self.reference(), + self.manifest_bytes(), + self.signature_bundle(), + &lock, + policy, + ); + if super::dispatch::verify_component_with(&request, check)? != *self { + return Err(invalid("retained component publisher identity changed").into()); + } + Ok(()) + } +} + +/// Local verified bytes, not a tar extraction or a launch grant. +#[derive(Debug)] +pub struct VerifiedApplicationComponentInputs { + descriptor: VerifiedRegistryApplicationComponent, + snapshot: tempfile::NamedTempFile, + group: Option>, + profile: Option>, +} + +impl VerifiedApplicationComponentInputs { + /// Signed component requirements and archive pin. + pub fn descriptor(&self) -> &VerifiedRegistryApplicationComponent { + &self.descriptor + } + + /// Exact signed group policy bytes, when declared. + pub fn group(&self) -> Option<&[u8]> { + self.group.as_deref() + } + + /// Exact signed profile policy bytes, when declared. + pub fn profile(&self) -> Option<&[u8]> { + self.profile.as_deref() + } + + /// Recheck publisher enrollment, revocations, snapshot and policy bytes. + /// + /// # Errors + /// + /// Refuses changed evidence or payload and stale or withdrawn trust. + pub fn reverify(&self) -> Result<(), ApplicationComponentInputError> { + self.descriptor.reverify_publisher()?; + self.descriptor.check_current_revocations()?; + self.reverify_bytes() + } + + fn reverify_bytes(&self) -> Result<(), ApplicationComponentInputError> { + let expected = &self.descriptor.manifest().application_component.archive; + let mut reader = self.snapshot.reopen()?; + if reader.metadata()?.len() != expected.archive_size { + return Err(ApplicationComponentInputError::Size); + } + let mut hasher = HashingWriter::new(std::io::sink()); + std::io::copy( + &mut reader.by_ref().take(expected.archive_size + 1), + &mut hasher, + )?; + let (size, digest) = hasher.finish(); + if size != expected.archive_size || digest != expected.archive_sha256.as_str() { + return Err(ApplicationComponentInputError::Digest); + } + for file in &self.descriptor.manifest().files { + let bytes = match file.path.as_str() { + GROUP_FILE => self.group(), + PROFILE_FILE => self.profile(), + _ => None, + } + .ok_or(ApplicationComponentInputError::Digest)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(bytes) != file.sha256 { + return Err(ApplicationComponentInputError::Digest); + } + } + Ok(()) + } + + /// Recheck trust and return a private archive stream; no extraction occurs. + /// + /// # Errors + /// + /// Refuses altered bytes or withdrawn trust. + pub fn open_verified_archive(&self) -> Result { + self.reverify()?; + Ok(std::fs::File::open(self.snapshot.path())?) + } +} + +/// Failure to authenticate a component's descriptor or locally supplied bytes. +#[derive(Debug, Error)] +pub enum ApplicationComponentInputError { + #[error(transparent)] + Verification(#[from] RegistryPackVerificationError), + #[error(transparent)] + Trust(#[from] RegistryPackStoreError), + #[error(transparent)] + Lock(#[from] super::RegistryPackError), + #[error("application component input I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("application component archive is not a regular file matching its signed size")] + Size, + #[error("application component bytes differ from their signed descriptor")] + Digest, +} + +/// Snapshot and hash an external archive and all signed policy files. +/// +/// # Errors +/// +/// Refuses missing, linked, special, changed, oversized or mismatched bytes, +/// and checks current publisher trust and revocation before returning custody. +pub fn verify_application_component_inputs( + descriptor: &VerifiedRegistryApplicationComponent, + archive: &Path, + files_root: &Path, +) -> Result { + verify_inputs_with( + descriptor, + archive, + files_root, + VerifiedRegistryApplicationComponent::reverify_publisher, + |descriptor| descriptor.check_current_revocations().map_err(Into::into), + ) +} + +fn verify_inputs_with( + descriptor: &VerifiedRegistryApplicationComponent, + archive: &Path, + files_root: &Path, + publisher: impl FnOnce( + &VerifiedRegistryApplicationComponent, + ) -> Result<(), ApplicationComponentInputError>, + revocations: impl FnOnce( + &VerifiedRegistryApplicationComponent, + ) -> Result<(), ApplicationComponentInputError>, +) -> Result { + publisher(descriptor)?; + revocations(descriptor)?; + let expected = &descriptor.manifest().application_component.archive; + let input = super::local_input::open_sized_regular(archive, expected.archive_size).map_err( + |error| { + if error.kind() == std::io::ErrorKind::Other { + ApplicationComponentInputError::Size + } else { + ApplicationComponentInputError::Io(error) + } + }, + )?; + let mut snapshot = tempfile::NamedTempFile::new()?; + let mut hasher = HashingWriter::new(snapshot.as_file_mut()); + std::io::copy(&mut input.take(expected.archive_size + 1), &mut hasher)?; + let (size, digest) = hasher.finish(); + if size != expected.archive_size { + return Err(ApplicationComponentInputError::Size); + } + if digest != expected.archive_sha256.as_str() { + return Err(ApplicationComponentInputError::Digest); + } + snapshot.as_file().sync_all()?; + let mut group = None; + let mut profile = None; + for file in &descriptor.manifest().files { + let mut source = super::local_input::open_payload(files_root, &file.path, file.size)?; + let before = source.metadata()?; + let mut bytes = Vec::new(); + source + .by_ref() + .take(file.size + 1) + .read_to_end(&mut bytes)?; + super::local_input::ensure_unchanged(&source, &before, files_root, &file.path, file.size)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(&bytes) != file.sha256 { + return Err(ApplicationComponentInputError::Digest); + } + match file.path.as_str() { + GROUP_FILE => group = Some(bytes), + PROFILE_FILE => profile = Some(bytes), + _ => return Err(ApplicationComponentInputError::Digest), + } + } + let held = VerifiedApplicationComponentInputs { + descriptor: descriptor.clone(), + snapshot, + group, + profile, + }; + held.reverify_bytes()?; + Ok(held) +} + +#[cfg(test)] +mod tests; diff --git a/crates/mvm-core/src/registry_pack/application_component/tests.rs b/crates/mvm-core/src/registry_pack/application_component/tests.rs new file mode 100644 index 0000000000..eeb8a532f6 --- /dev/null +++ b/crates/mvm-core/src/registry_pack/application_component/tests.rs @@ -0,0 +1,329 @@ +use serde_json::{Value, json}; + +use super::*; +use crate::registry_pack::tests::{accept_signature, publisher_policy}; +use crate::registry_pack::{PackLockfile, PackPin}; + +const POLICY: &[u8] = b"required = true\n"; +const ARCHIVE: &[u8] = b"application tar bytes"; + +fn document() -> Value { + json!({ + "schema_version": 3, + "kind": "application_component", + "reference": "runtime/worker@1.2.3", + "description": "portable worker", + "application_component": { + "schema_version": 1, + "guest_arch": "aarch64", + "requirements": { + "filesystem_abi": 1, + "libc": "musl", + "loader": "/lib/ld-musl-aarch64.so.1", + "minimum_kernel": "6.1.0", + "required_capabilities": ["pty"] + }, + "archive": { + "source": "https://example.test/worker.tar", + "archive_sha256": Sha256Hex::from_bytes(ARCHIVE), + "archive_size": ARCHIVE.len() + }, + "entrypoints": [ + {"name": "default", "argv": ["/usr/bin/worker", "--serve"]}, + {"name": "migrate", "argv": ["/usr/bin/worker", "--migrate"]} + ] + }, + "files": [{ + "path": "pack/group.toml", + "sha256": Sha256Hex::from_bytes(POLICY), + "size": POLICY.len() + }] + }) +} + +fn parse( + value: &Value, +) -> Result { + parse_application_component_manifest(&serde_json::to_vec(value).unwrap()) +} + +fn authenticated(value: &Value) -> VerifiedRegistryApplicationComponent { + let bytes = serde_json::to_vec(value).unwrap(); + let reference: PackReference = value["reference"].as_str().unwrap().parse().unwrap(); + let lock = PackLockfile::new(vec![ + PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(), + ]) + .unwrap(); + let policy = publisher_policy(); + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .unwrap() +} + +#[test] +fn component_has_exact_versioned_shape_and_does_not_fall_back() { + let manifest = parse(&document()).unwrap(); + assert_eq!(manifest.reference.to_string(), "runtime/worker@1.2.3"); + assert_eq!( + manifest.application_component.entrypoints[0].name, + "default" + ); + let bytes = serde_json::to_vec(&document()).unwrap(); + assert!(super::super::application::parse_application_manifest(&bytes).is_err()); + assert!(super::super::runtime_layer::parse_runtime_layer_manifest(&bytes).is_err()); + for (field, replacement) in [ + ("schema_version", json!(2)), + ("kind", json!("runtime_layer")), + ("reference", json!("runtime/worker@1.2.3+build")), + ("description", json!(" ")), + ] { + let mut value = document(); + value[field] = replacement; + assert!(parse(&value).is_err(), "{field}"); + } + for field in [ + "image", + "kernel", + "application", + "payload", + "network", + "role", + ] { + let mut value = document(); + value[field] = json!({}); + assert!(parse(&value).is_err(), "{field}"); + } + let mut duplicate = serde_json::to_string(&document()).unwrap(); + duplicate = duplicate.replacen( + "\"kind\":", + "\"kind\":\"application_component\",\"kind\":", + 1, + ); + assert!(parse_application_component_manifest(duplicate.as_bytes()).is_err()); + assert!( + parse_application_component_manifest(&vec![b' '; MAX_REGISTRY_DESCRIPTOR_BYTES + 1]) + .is_err() + ); +} + +#[test] +fn requirements_and_entrypoints_are_strict_and_bounded() { + let base = document(); + let mut cases = Vec::new(); + for (key, bad) in [ + ("filesystem_abi", json!(2)), + ("libc", json!("unknown")), + ("loader", json!("/lib/../ld.so")), + ("loader", json!("/lib/ld-musl-x86_64.so.1")), + ("minimum_kernel", json!("06.1.0")), + ("required_capabilities", json!(["pty", "pty"])), + ] { + let mut value = base.clone(); + value["application_component"]["requirements"][key] = bad; + cases.push(value); + } + for (key, bad) in [ + ("source", json!("http://example.test/app.tar")), + ("archive_sha256", json!("A".repeat(64))), + ("archive_size", json!(MAX_COMPONENT_BYTES + 1)), + ] { + let mut value = base.clone(); + value["application_component"]["archive"][key] = bad; + cases.push(value); + } + for (key, bad) in [ + ("name", json!("NotCanonical")), + ("argv", json!(["relative"])), + ] { + let mut value = base.clone(); + value["application_component"]["entrypoints"][0][key] = bad; + cases.push(value); + } + let mut value = base.clone(); + value["application_component"]["requirements"]["loader"] = Value::Null; + cases.push(value); + let mut value = base.clone(); + value["application_component"]["requirements"]["libc"] = json!("static"); + cases.push(value); + let mut value = base.clone(); + value["application_component"]["entrypoints"][1]["name"] = json!("default"); + cases.push(value); + let mut value = base.clone(); + value["application_component"]["entrypoints"][0]["argv"] = json!(["/bin/app", "\0"]); + cases.push(value); + for (index, value) in cases.iter().enumerate() { + assert!(parse(value).is_err(), "invalid case {index}"); + } + let mut static_ok = base; + static_ok["application_component"]["requirements"]["libc"] = json!("static"); + static_ok["application_component"]["requirements"] + .as_object_mut() + .unwrap() + .remove("loader"); + static_ok["application_component"]["entrypoints"] = json!([]); + assert!(parse(&static_ok).is_ok()); +} + +#[test] +fn policy_inventory_requires_exact_signed_files() { + let base = document(); + for bad in [ + json!([]), + json!([{"path": "pack/other.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": POLICY.len()}]), + json!([base["files"][0].clone(), base["files"][0].clone()]), + json!([{"path": "pack/group.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": 0}]), + ] { + let mut value = base.clone(); + value["files"] = bad; + assert!(parse(&value).is_err()); + } + let mut good = base; + good["files"].as_array_mut().unwrap().push(json!({ + "path": "pack/profile.toml", "sha256": Sha256Hex::from_bytes(b"profile"), "size": 7 + })); + assert!(parse(&good).is_ok()); +} + +#[test] +fn locked_publisher_identity_and_retained_bytes_are_bound() { + let value = document(); + let bytes = serde_json::to_vec(&value).unwrap(); + let reference: PackReference = "runtime/worker@1.2.3".parse().unwrap(); + let lock = PackLockfile::new(vec![ + PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(), + ]) + .unwrap(); + let policy = publisher_policy(); + let request = RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy); + let verified = + super::super::dispatch::verify_component_with(&request, accept_signature).unwrap(); + assert_eq!(verified.manifest_bytes(), bytes); + assert_eq!(verified.signature_bundle(), b"signed"); + assert_eq!(verified.manifest_sha256(), &Sha256Hex::from_bytes(&bytes)); + verified + .reverify_publisher_with(&policy, accept_signature) + .unwrap(); + assert!( + verified + .reverify_publisher_with( + &RegistryPackPublisherPolicy::new(vec![]).unwrap(), + accept_signature + ) + .is_err() + ); + let mut altered = bytes.clone(); + altered.push(b' '); + assert!( + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&reference, &altered, b"signed", &lock, &policy), + accept_signature, + ) + .is_err() + ); + let other: PackReference = "runtime/other@1.2.3".parse().unwrap(); + assert!( + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&other, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .is_err() + ); +} + +#[test] +fn authenticated_component_uses_signed_current_revocation_feed() { + use crate::packs::KeylessTrust; + use crate::registry_pack_revocation::{ + RegistryPackRevocationError, verify_registry_pack_revocations_with, + }; + + let descriptor = authenticated(&document()); + let now = chrono::Utc::now(); + let trust = KeylessTrust { + issuer: "test revocation issuer".into(), + accepted_identities: vec!["test revocation signer".into()], + }; + for (identities, digests, revoked) in [ + (Vec::new(), Vec::new(), false), + (vec![descriptor.signer().identity.clone()], Vec::new(), true), + (Vec::new(), vec![descriptor.manifest_sha256().clone()], true), + ] { + let document = json!({ + "schema_version": 1, + "sequence": 1, + "issued_at": now - chrono::Duration::hours(1), + "not_after": now + chrono::Duration::hours(1), + "revoked_identities": identities, + "revoked_manifests": digests, + }); + let feed = verify_registry_pack_revocations_with( + &serde_json::to_vec(&document).unwrap(), + b"signed feed", + &trust, + now, + None, + |_, _, _| Ok(()), + ) + .unwrap(); + let result = feed.check_verified_pack_at(&descriptor, now); + assert_eq!(result.is_err(), revoked); + if revoked { + assert!(matches!( + result, + Err(RegistryPackRevocationError::RevokedIdentity { .. } + | RegistryPackRevocationError::RevokedManifest { .. }) + )); + } + assert!(matches!( + feed.check_verified_pack_at(&descriptor, now + chrono::Duration::hours(2)), + Err(RegistryPackRevocationError::Expired) + )); + } +} + +#[test] +fn private_snapshots_refuse_changed_archive_and_policy() { + let descriptor = authenticated(&document()); + let temporary = tempfile::tempdir().unwrap(); + let archive = temporary.path().join("app.tar"); + let policies = temporary.path().join("payload"); + std::fs::create_dir_all(policies.join("pack")).unwrap(); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(policies.join(GROUP_FILE), POLICY).unwrap(); + // The production entrypoint also requires current publisher enrollment and + // a signed feed; exercise its byte-verification core without a network feed. + std::fs::write(&archive, b"wrong length").unwrap(); + assert!(verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(&archive, b"wrong archive bytes!!").unwrap(); + assert!(matches!( + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())), + Err(ApplicationComponentInputError::Digest) + )); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(policies.join(GROUP_FILE), b"wrong policy...\n").unwrap(); + assert!(matches!( + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())), + Err(ApplicationComponentInputError::Digest) + )); + std::fs::write(policies.join(GROUP_FILE), POLICY).unwrap(); + let input = + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())).unwrap(); + std::fs::write(&archive, b"different source").unwrap(); + std::fs::write(policies.join(GROUP_FILE), b"changed policy").unwrap(); + assert!( + input.reverify_bytes().is_ok(), + "held bytes outlive mutable source paths" + ); + assert_eq!(input.group(), Some(POLICY)); + let mut reader = input.snapshot.reopen().unwrap(); + let mut contents = Vec::new(); + reader.read_to_end(&mut contents).unwrap(); + assert_eq!(contents, ARCHIVE); + std::fs::write(input.snapshot.path(), b"changed snapshot").unwrap(); + assert!(matches!( + input.reverify_bytes(), + Err(ApplicationComponentInputError::Size | ApplicationComponentInputError::Digest) + )); +} diff --git a/crates/mvm-core/src/registry_pack/component_layer.rs b/crates/mvm-core/src/registry_pack/component_layer.rs new file mode 100644 index 0000000000..0a3a30c21b --- /dev/null +++ b/crates/mvm-core/src/registry_pack/component_layer.rs @@ -0,0 +1,641 @@ +//! Strict signed runtime and tool components, without legacy-layer fallback. +//! +//! A descriptor authenticates immutable bytes and compatibility requirements. +//! It does not prove a selected base satisfies those requirements or grant +//! permission to extract an archive or apply policy. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::application::{deserialize_object, validate_application_source}; +use super::application_component::{ + ComponentArchive, ComponentRequirements, validate_component_requirements, +}; +use super::{ + MAX_REGISTRY_DESCRIPTOR_BYTES, PackAdoption, PackLockfile, PackPin, PackReference, + RegistryPackFile, RegistryPackPublisherPolicy, RegistryPackSignatureChecker, + RegistryPackVerification, RegistryPackVerificationError, +}; +use crate::crypto::image_verify::VerifiedSigner; +use crate::packs::Sha256Hex; +use crate::plan::bundle::HashingWriter; +use crate::registry_pack_store::RegistryPackStoreError; + +/// Outer schema for strictly versioned, nonbootable runtime and tool components. +pub const COMPONENT_LAYER_MANIFEST_SCHEMA_VERSION: u32 = 4; +const MAX_COMPONENT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_DEPENDENCIES: usize = 16; +const MAX_RESOLVED_COMPONENTS: usize = 64; +const GROUP_FILE: &str = "pack/group.toml"; +const PROFILE_FILE: &str = "pack/profile.toml"; + +/// The only two component-layer roles. A schema 3 runtime layer is not one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComponentLayerKind { + Runtime, + Tool, +} + +/// One architecture's signed compatibility requirements and immutable inputs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentLayer { + pub schema_version: u32, + pub guest_arch: String, + #[serde(deserialize_with = "deserialize_object")] + pub requirements: ComponentRequirements, + pub dependencies: Vec, + #[serde(deserialize_with = "deserialize_object")] + pub archive: ComponentArchive, +} + +/// Closed signed wire shape; the kind determines the sole permitted nested field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RegistryComponentLayerManifest { + RuntimeComponent { + schema_version: u32, + reference: PackReference, + description: String, + #[serde(deserialize_with = "deserialize_object")] + runtime_component: ComponentLayer, + files: Vec, + }, + ToolComponent { + schema_version: u32, + reference: PackReference, + description: String, + #[serde(deserialize_with = "deserialize_object")] + tool_component: ComponentLayer, + files: Vec, + }, +} + +impl RegistryComponentLayerManifest { + /// Runtime or tool role selected by the exact signed kind. + pub fn kind(&self) -> ComponentLayerKind { + match self { + Self::RuntimeComponent { .. } => ComponentLayerKind::Runtime, + Self::ToolComponent { .. } => ComponentLayerKind::Tool, + } + } + + /// Exact versioned pack identity. + pub fn reference(&self) -> &PackReference { + match self { + Self::RuntimeComponent { reference, .. } | Self::ToolComponent { reference, .. } => { + reference + } + } + } + + /// Signed requirements, ordered dependencies and archive bytes. + pub fn component(&self) -> &ComponentLayer { + match self { + Self::RuntimeComponent { + runtime_component, .. + } => runtime_component, + Self::ToolComponent { tool_component, .. } => tool_component, + } + } + + /// Signed policy inventory, not authorization to apply its contents. + pub fn files(&self) -> &[RegistryPackFile] { + match self { + Self::RuntimeComponent { files, .. } | Self::ToolComponent { files, .. } => files, + } + } +} + +fn invalid(reason: &str) -> RegistryPackVerificationError { + RegistryPackVerificationError::ManifestParse(reason.to_string()) +} + +/// Parse only schema 4 runtime/tool component descriptors with closed shapes. +/// +/// # Errors +/// +/// Refuses oversized, unknown, duplicate, hybrid, unversioned or noncanonical data. +/// No publisher, external archive or policy bytes are authenticated by parsing. +pub fn parse_component_layer_manifest( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_REGISTRY_DESCRIPTOR_BYTES { + return Err(invalid("component layer descriptor exceeds 1 MiB")); + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let manifest: RegistryComponentLayerManifest = deserialize_object(&mut deserializer) + .map_err(|_| invalid("component layer descriptor has invalid or non-closed JSON"))?; + deserializer + .end() + .map_err(|_| invalid("component layer descriptor has trailing data"))?; + let (schema_version, description) = match &manifest { + RegistryComponentLayerManifest::RuntimeComponent { + schema_version, + description, + .. + } + | RegistryComponentLayerManifest::ToolComponent { + schema_version, + description, + .. + } => (*schema_version, description), + }; + let component = manifest.component(); + let reference = manifest.reference(); + if schema_version != COMPONENT_LAYER_MANIFEST_SCHEMA_VERSION || component.schema_version != 1 { + return Err(invalid( + "component layer requires outer schema 4 and nested schema 1", + )); + } + let namespace = match manifest.kind() { + ComponentLayerKind::Runtime => "runtime", + ComponentLayerKind::Tool => "tool", + }; + if reference.namespace() != namespace + || reference + .version() + .is_none_or(|version| version.as_str().contains('+')) + || description.trim().is_empty() + { + return Err(invalid( + "component layer requires a matching, versioned identity and description", + )); + } + if !matches!(component.guest_arch.as_str(), "aarch64" | "x86_64") { + return Err(invalid( + "component layer guest architecture is not canonical", + )); + } + validate_component_requirements(&component.requirements, &component.guest_arch)?; + if component.archive.archive_size == 0 || component.archive.archive_size > MAX_COMPONENT_BYTES { + return Err(invalid("component layer archive size is out of bounds")); + } + validate_application_source(&component.archive.source)?; + if component.dependencies.len() > MAX_DEPENDENCIES { + return Err(invalid("component layer declares too many dependencies")); + } + let mut dependencies = BTreeSet::new(); + for dependency in &component.dependencies { + let reference = dependency.reference(); + if !matches!(reference.namespace(), "runtime" | "tool") + || reference + .version() + .is_none_or(|version| version.as_str().contains('+')) + || reference.coordinate_string() == manifest.reference().coordinate_string() + || !dependencies.insert(reference.coordinate_string()) + { + return Err(invalid( + "component layer has a duplicate, self or non-component dependency", + )); + } + } + let mut paths = BTreeSet::new(); + if manifest.files().is_empty() + || manifest.files().len() > 2 + || manifest.files().iter().any(|file| { + !matches!(file.path.as_str(), GROUP_FILE | PROFILE_FILE) + || !paths.insert(file.path.as_str()) + || file.size == 0 + || file.size > MAX_REGISTRY_DESCRIPTOR_BYTES as u64 + }) + { + return Err(invalid( + "component layer requires one or two pinned policy documents", + )); + } + Ok(manifest) +} + +/// Publisher-authenticated component metadata, not an installed or executable pack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRegistryComponentLayer { + manifest: RegistryComponentLayerManifest, + manifest_bytes: Vec, + signature_bundle: Vec, + manifest_sha256: Sha256Hex, + signer: VerifiedSigner, +} + +impl VerifiedRegistryComponentLayer { + /// Strictly parsed signed descriptor. + pub fn manifest(&self) -> &RegistryComponentLayerManifest { + &self.manifest + } + + /// Exact publisher-signed bytes, without reserialization. + pub fn manifest_bytes(&self) -> &[u8] { + &self.manifest_bytes + } + + /// Detached signature evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// Digest of the exact original signed descriptor bytes. + pub fn manifest_sha256(&self) -> &Sha256Hex { + &self.manifest_sha256 + } + + /// Publisher identity verified under the namespace's enrolled authority. + pub fn signer(&self) -> &VerifiedSigner { + &self.signer + } + + /// Require a current authenticated feed, with no legacy absent-feed fallback. + /// + /// # Errors + /// + /// Refuses revoked identities or manifests, expired feeds or missing trust. + pub fn check_current_revocations(&self) -> Result<(), RegistryPackStoreError> { + crate::registry_pack_store::check_application_revocations(self) + } + + /// Recheck signed evidence against the current publisher enrollment. + /// + /// # Errors + /// + /// Refuses withdrawn or changed publisher authority. + pub fn reverify_publisher(&self) -> Result<(), ComponentLayerInputError> { + let loaded = crate::registry_pack_store::load_publisher_policy_or_official_default( + &crate::config::registry_pack_publisher_policy_path(), + )?; + self.reverify_publisher_with(&loaded.policy, super::check_registry_pack_signature) + } + + fn reverify_publisher_with( + &self, + policy: &RegistryPackPublisherPolicy, + check: RegistryPackSignatureChecker, + ) -> Result<(), ComponentLayerInputError> { + let pin = PackPin::new( + self.manifest.reference().clone(), + self.manifest_sha256.clone(), + )?; + let lock = PackLockfile::new(vec![pin])?; + let request = RegistryPackVerification::new( + self.manifest.reference(), + &self.manifest_bytes, + &self.signature_bundle, + &lock, + policy, + ); + if verify_component_layer_with(&request, check)? != *self { + return Err(invalid("retained component layer publisher identity changed").into()); + } + Ok(()) + } +} + +/// Verify the exact lock pin, enrolled namespace signer and current revocations. +/// +/// # Errors +/// +/// Refuses drifted pins, untrusted signatures, invalid descriptors or revoked +/// metadata. No archive is acquired and no base compatibility is asserted. +pub fn verify_component_layer_manifest( + request: &RegistryPackVerification<'_>, +) -> Result { + let verified = verify_component_layer_with(request, super::check_registry_pack_signature)?; + verified.check_current_revocations()?; + Ok(verified) +} + +fn verify_component_layer_with( + request: &RegistryPackVerification<'_>, + check: RegistryPackSignatureChecker, +) -> Result { + match super::dispatch::verify_with(request, check)? { + super::VerifiedRegistryManifest::ComponentLayer(component) => Ok(component), + _ => Err(invalid("pinned pack is not a runtime or tool component")), + } +} + +/// Complete the typed component branch after dispatch verifies the publisher. +pub(super) fn finish_component_layer( + request: &PackAdoption<'_>, + signer: VerifiedSigner, +) -> Result { + let manifest = parse_component_layer_manifest(request.manifest_bytes)?; + let reference = manifest.reference(); + if reference.coordinate != request.requested.coordinate + || request + .requested + .version + .as_ref() + .is_some_and(|version| Some(version) != reference.version.as_ref()) + { + return Err(RegistryPackVerificationError::ManifestReferenceMismatch { + manifest: reference.to_string(), + locked: request.requested.to_string(), + }); + } + let manifest_sha256 = Sha256Hex::from_bytes(request.manifest_bytes); + super::historical::check_legacy_manifest_policy( + request.publisher_policy, + &signer.identity, + reference, + &manifest_sha256, + )?; + Ok(VerifiedRegistryComponentLayer { + manifest, + manifest_sha256, + signer, + manifest_bytes: request.manifest_bytes.to_vec(), + signature_bundle: request.signature_bundle.to_vec(), + }) +} + +/// Failure to resolve exact signed runtime/tool dependencies. +#[derive(Debug, Error)] +pub enum ComponentLayerResolveError { + #[error("component graph exceeds its bound or contains duplicate names")] + InvalidGraph, + #[error("component dependency is missing: {0}")] + Missing(String), + #[error("component dependency pin or architecture differs: {0}")] + Drift(String), + #[error("component dependency cycle at {0}")] + Cycle(String), + #[error(transparent)] + Revocation(#[from] RegistryPackStoreError), +} + +/// Resolve exact signed pins into dependency-first order, checking revocations. +/// +/// This accepts only schema 4 component metadata and cannot silently upgrade +/// schema 3 runtime layers. The returned metadata is not base compatibility or +/// permission to unpack an archive or apply policy. +/// +/// # Errors +/// +/// Refuses absent, drifted, duplicate, cyclic, wrong-architecture or revoked +/// components. Every selected manifest needs a current signed revocation feed. +pub fn resolve_component_layers<'a>( + roots: &[PackPin], + candidates: &'a [VerifiedRegistryComponentLayer], + arch: &str, +) -> Result, ComponentLayerResolveError> { + let ordered = resolve_graph(roots, candidates, arch)?; + for component in &ordered { + component.check_current_revocations()?; + } + Ok(ordered) +} + +fn resolve_graph<'a>( + roots: &[PackPin], + candidates: &'a [VerifiedRegistryComponentLayer], + arch: &str, +) -> Result, ComponentLayerResolveError> { + if !matches!(arch, "x86_64" | "aarch64") + || roots.len() > MAX_RESOLVED_COMPONENTS + || candidates.len() > MAX_RESOLVED_COMPONENTS + { + return Err(ComponentLayerResolveError::InvalidGraph); + } + let mut by_name = BTreeMap::new(); + for component in candidates { + if by_name + .insert( + component.manifest().reference().coordinate_string(), + component, + ) + .is_some() + { + return Err(ComponentLayerResolveError::InvalidGraph); + } + } + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut root_names = BTreeSet::new(); + let mut ordered = Vec::new(); + for root in roots { + if !root_names.insert(root.reference().coordinate_string()) { + return Err(ComponentLayerResolveError::InvalidGraph); + } + visit( + root, + arch, + &by_name, + &mut visiting, + &mut visited, + &mut ordered, + )?; + } + Ok(ordered) +} + +fn visit<'a>( + pin: &PackPin, + arch: &str, + by_name: &BTreeMap, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + ordered: &mut Vec<&'a VerifiedRegistryComponentLayer>, +) -> Result<(), ComponentLayerResolveError> { + let name = pin.reference().coordinate_string(); + let component = by_name + .get(&name) + .ok_or_else(|| ComponentLayerResolveError::Missing(pin.reference().to_string()))?; + if component.manifest().reference() != pin.reference() + || component.manifest_sha256() != pin.manifest_sha256() + || component.manifest().component().guest_arch != arch + { + return Err(ComponentLayerResolveError::Drift( + pin.reference().to_string(), + )); + } + if visiting.contains(&name) { + return Err(ComponentLayerResolveError::Cycle(name)); + } + if visited.contains(&name) { + return Ok(()); + } + visiting.insert(name.clone()); + for dependency in &component.manifest().component().dependencies { + visit(dependency, arch, by_name, visiting, visited, ordered)?; + } + visiting.remove(&name); + visited.insert(name); + ordered.push(component); + Ok(()) +} + +/// Failure to authenticate or hold a complete component input. +#[derive(Debug, Error)] +pub enum ComponentLayerInputError { + #[error(transparent)] + Verification(#[from] RegistryPackVerificationError), + #[error(transparent)] + Trust(#[from] RegistryPackStoreError), + #[error(transparent)] + Lock(#[from] super::RegistryPackError), + #[error("component layer input I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("component layer archive is not a regular file matching its signed size")] + Size, + #[error("component layer bytes differ from their signed descriptor")] + Digest, +} + +/// A private snapshot and signed policies, not an extracted or admitted layer. +#[derive(Debug)] +pub struct VerifiedComponentLayerInputs { + descriptor: VerifiedRegistryComponentLayer, + snapshot: tempfile::NamedTempFile, + group: Option>, + profile: Option>, +} + +impl VerifiedComponentLayerInputs { + /// Authenticated typed metadata, including requirements and ordered pins. + pub fn descriptor(&self) -> &VerifiedRegistryComponentLayer { + &self.descriptor + } + + /// Exact signed group policy bytes, if declared. + pub fn group(&self) -> Option<&[u8]> { + self.group.as_deref() + } + + /// Exact signed profile policy bytes, if declared. + pub fn profile(&self) -> Option<&[u8]> { + self.profile.as_deref() + } + + /// Recheck publisher, revocations, archive and held policy bytes. + /// + /// # Errors + /// + /// Refuses withdrawn trust or changed held bytes. + pub fn reverify(&self) -> Result<(), ComponentLayerInputError> { + self.descriptor.reverify_publisher()?; + self.descriptor.check_current_revocations()?; + self.reverify_bytes() + } + + fn reverify_bytes(&self) -> Result<(), ComponentLayerInputError> { + let expected = &self.descriptor.manifest.component().archive; + let reader = self.snapshot.reopen()?; + if reader.metadata()?.len() != expected.archive_size { + return Err(ComponentLayerInputError::Size); + } + let mut writer = HashingWriter::new(std::io::sink()); + std::io::copy(&mut reader.take(expected.archive_size + 1), &mut writer)?; + let (size, digest) = writer.finish(); + if size != expected.archive_size || digest != expected.archive_sha256.as_str() { + return Err(ComponentLayerInputError::Digest); + } + for file in self.descriptor.manifest.files() { + let bytes = match file.path.as_str() { + GROUP_FILE => self.group(), + PROFILE_FILE => self.profile(), + _ => None, + } + .ok_or(ComponentLayerInputError::Digest)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(bytes) != file.sha256 { + return Err(ComponentLayerInputError::Digest); + } + } + Ok(()) + } + + /// Open the private archive after rechecking trust and exact byte identity. + /// + /// # Errors + /// + /// Refuses changed bytes, publisher policy or revocation feed. + pub fn open_verified_archive(&self) -> Result { + self.reverify()?; + Ok(std::fs::File::open(self.snapshot.path())?) + } +} + +/// Snapshot exact external archive bytes and every signed policy document. +/// +/// # Errors +/// +/// Refuses missing, linked, special, changed, oversized or mismatched inputs. +/// This does not verify archive entries or prove selected-base compatibility. +pub fn verify_component_layer_inputs( + descriptor: &VerifiedRegistryComponentLayer, + archive: &Path, + files_root: &Path, +) -> Result { + verify_inputs_with( + descriptor, + archive, + files_root, + VerifiedRegistryComponentLayer::reverify_publisher, + |descriptor| descriptor.check_current_revocations().map_err(Into::into), + ) +} + +fn verify_inputs_with( + descriptor: &VerifiedRegistryComponentLayer, + archive: &Path, + files_root: &Path, + publisher: impl FnOnce(&VerifiedRegistryComponentLayer) -> Result<(), ComponentLayerInputError>, + revocations: impl FnOnce(&VerifiedRegistryComponentLayer) -> Result<(), ComponentLayerInputError>, +) -> Result { + publisher(descriptor)?; + revocations(descriptor)?; + let expected = &descriptor.manifest.component().archive; + let input = super::local_input::open_sized_regular(archive, expected.archive_size).map_err( + |error| { + if error.kind() == std::io::ErrorKind::Other { + ComponentLayerInputError::Size + } else { + ComponentLayerInputError::Io(error) + } + }, + )?; + let mut snapshot = tempfile::NamedTempFile::new()?; + let mut writer = HashingWriter::new(snapshot.as_file_mut()); + std::io::copy(&mut input.take(expected.archive_size + 1), &mut writer)?; + let (size, digest) = writer.finish(); + if size != expected.archive_size { + return Err(ComponentLayerInputError::Size); + } + if digest != expected.archive_sha256.as_str() { + return Err(ComponentLayerInputError::Digest); + } + snapshot.as_file().sync_all()?; + let mut group = None; + let mut profile = None; + for file in descriptor.manifest.files() { + let mut source = super::local_input::open_payload(files_root, &file.path, file.size)?; + let before = source.metadata()?; + let mut bytes = Vec::new(); + source + .by_ref() + .take(file.size + 1) + .read_to_end(&mut bytes)?; + super::local_input::ensure_unchanged(&source, &before, files_root, &file.path, file.size)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(&bytes) != file.sha256 { + return Err(ComponentLayerInputError::Digest); + } + match file.path.as_str() { + GROUP_FILE => group = Some(bytes), + PROFILE_FILE => profile = Some(bytes), + _ => return Err(ComponentLayerInputError::Digest), + } + } + let held = VerifiedComponentLayerInputs { + descriptor: descriptor.clone(), + snapshot, + group, + profile, + }; + held.reverify_bytes()?; + Ok(held) +} + +#[cfg(test)] +mod tests; diff --git a/crates/mvm-core/src/registry_pack/component_layer/tests.rs b/crates/mvm-core/src/registry_pack/component_layer/tests.rs new file mode 100644 index 0000000000..b5fddf30a5 --- /dev/null +++ b/crates/mvm-core/src/registry_pack/component_layer/tests.rs @@ -0,0 +1,437 @@ +use serde_json::{Value, json}; + +use super::*; +use crate::registry_pack::tests::{accept_signature, publisher_policy, reject_signature}; +use crate::registry_pack::{ + OFFICIAL_PACK_SIGNING_IDENTITY, OFFICIAL_PACK_SIGNING_ISSUER, RegistryPackPublisher, +}; + +const ARCHIVE: &[u8] = b"component archive"; +const POLICY: &[u8] = b"required = true\n"; + +fn document(kind: ComponentLayerKind) -> Value { + let (kind_name, field, reference) = match kind { + ComponentLayerKind::Runtime => ( + "runtime_component", + "runtime_component", + "runtime/python@1.2.3", + ), + ComponentLayerKind::Tool => ("tool_component", "tool_component", "tool/git@1.2.3"), + }; + let mut value = json!({ + "schema_version": 4, + "kind": kind_name, + "reference": reference, + "description": "portable signed component", + "files": [{ + "path": "pack/group.toml", + "sha256": Sha256Hex::from_bytes(POLICY), + "size": POLICY.len() + }] + }); + value[field] = json!({ + "schema_version": 1, + "guest_arch": "aarch64", + "requirements": { + "filesystem_abi": 1, + "libc": "musl", + "loader": "/lib/ld-musl-aarch64.so.1", + "minimum_kernel": "6.1.0", + "required_capabilities": ["pty"] + }, + "dependencies": [{ + "reference": "runtime/base@1.0.0", + "manifest_sha256": Sha256Hex::from_bytes(b"base manifest") + }, { + "reference": "tool/helper@2.0.0", + "manifest_sha256": Sha256Hex::from_bytes(b"helper manifest") + }], + "archive": { + "source": "https://example.test/component.tar", + "archive_sha256": Sha256Hex::from_bytes(ARCHIVE), + "archive_size": ARCHIVE.len() + } + }); + value +} + +fn parse(value: &Value) -> Result { + parse_component_layer_manifest(&serde_json::to_vec(value).unwrap()) +} + +fn policy_for(reference: &PackReference) -> RegistryPackPublisherPolicy { + if reference.namespace() == "tool" { + RegistryPackPublisherPolicy::new(vec![ + RegistryPackPublisher::new( + "tool", + OFFICIAL_PACK_SIGNING_ISSUER, + vec![OFFICIAL_PACK_SIGNING_IDENTITY.to_owned()], + ) + .unwrap(), + ]) + .unwrap() + } else { + publisher_policy() + } +} + +fn authenticated(value: &Value) -> VerifiedRegistryComponentLayer { + let bytes = serde_json::to_vec(value).unwrap(); + let reference: PackReference = value["reference"].as_str().unwrap().parse().unwrap(); + let pin = PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(); + let lock = PackLockfile::new(vec![pin]).unwrap(); + let policy = policy_for(&reference); + verify_component_layer_with( + &RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .unwrap() +} + +#[test] +fn both_kinds_have_closed_schema4_shapes_and_roundtrip() { + for kind in [ComponentLayerKind::Runtime, ComponentLayerKind::Tool] { + let value = document(kind); + let parsed = parse(&value).unwrap(); + assert_eq!(parsed.kind(), kind); + assert_eq!(parsed.component().dependencies.len(), 2); + assert_eq!(parsed.component().requirements.minimum_kernel, "6.1.0"); + assert_eq!( + parse(&serde_json::to_value(&parsed).unwrap()).unwrap(), + parsed + ); + let bytes = serde_json::to_vec(&value).unwrap(); + assert!(super::super::runtime_layer::parse_runtime_layer_manifest(&bytes).is_err()); + assert!( + super::super::application_component::parse_application_component_manifest(&bytes) + .is_err() + ); + let (field, other) = match kind { + ComponentLayerKind::Runtime => ("runtime_component", "tool_component"), + ComponentLayerKind::Tool => ("tool_component", "runtime_component"), + }; + for (path, replacement) in [ + (vec!["schema_version"], json!(3)), + (vec!["description"], json!(" ")), + (vec![field, "schema_version"], json!(2)), + (vec![field, "guest_arch"], json!("amd64")), + (vec![field, "archive", "archive_size"], json!(0)), + ( + vec![field, "archive", "source"], + json!("http://example.test/app.tar"), + ), + (vec![field, "requirements", "filesystem_abi"], json!(2)), + ( + vec![field, "requirements", "loader"], + json!("/lib/ld-musl-x86_64.so.1"), + ), + ( + vec![field, "requirements", "required_capabilities"], + json!(["pty", "pty"]), + ), + (vec![field, "requirements", "loader"], Value::Null), + ] { + let mut changed = value.clone(); + let mut slot = &mut changed; + for segment in path { + slot = &mut slot[segment]; + } + *slot = replacement; + assert!(parse(&changed).is_err(), "{changed}"); + } + let mut hybrid = value.clone(); + hybrid[other] = value[field].clone(); + assert!(parse(&hybrid).is_err()); + let mut unknown = value.clone(); + unknown[field]["role"] = json!("application"); + assert!(parse(&unknown).is_err()); + let mut null = value.clone(); + null[field] = Value::Null; + assert!(parse(&null).is_err()); + let mut duplicate = serde_json::to_string(&value).unwrap(); + duplicate = duplicate.replacen("\"kind\":", "\"kind\":\"runtime_component\",\"kind\":", 1); + assert!(parse_component_layer_manifest(duplicate.as_bytes()).is_err()); + } + assert!( + parse_component_layer_manifest(&vec![b' '; MAX_REGISTRY_DESCRIPTOR_BYTES + 1]).is_err() + ); +} + +#[test] +fn namespace_requirements_and_dependencies_are_exact() { + for kind in [ComponentLayerKind::Runtime, ComponentLayerKind::Tool] { + let value = document(kind); + let field = match kind { + ComponentLayerKind::Runtime => "runtime_component", + ComponentLayerKind::Tool => "tool_component", + }; + for bad in [ + "agent/worker@1.2.3", + "runtime/worker@1.2.3+build", + "tool/worker", + ] { + let mut changed = value.clone(); + changed["reference"] = json!(bad); + assert!(parse(&changed).is_err(), "{bad}"); + } + for bad in [ + "agent/worker@1.0.0", + "runtime/base", + "runtime/base@1.0.0+build", + value["reference"].as_str().unwrap(), + ] { + let mut changed = value.clone(); + changed[field]["dependencies"][0]["reference"] = json!(bad); + assert!(parse(&changed).is_err(), "{bad}"); + } + let mut duplicate = value.clone(); + duplicate[field]["dependencies"][1]["reference"] = json!("runtime/base@2.0.0"); + assert!(parse(&duplicate).is_err()); + let mut too_many = value.clone(); + too_many[field]["dependencies"] = json!(vec![value[field]["dependencies"][0].clone(); 17]); + assert!(parse(&too_many).is_err()); + let mut static_ok = value.clone(); + static_ok[field]["requirements"]["libc"] = json!("static"); + static_ok[field]["requirements"] + .as_object_mut() + .unwrap() + .remove("loader"); + assert!(parse(&static_ok).is_ok()); + } +} + +#[test] +fn files_only_name_signed_policy_documents() { + let base = document(ComponentLayerKind::Runtime); + for files in [ + json!([]), + json!([base["files"][0].clone(), base["files"][0].clone()]), + json!([{"path": "pack/other.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": POLICY.len()}]), + json!([{"path": "pack/group.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": 0}]), + ] { + let mut changed = base.clone(); + changed["files"] = files; + assert!(parse(&changed).is_err()); + } + let mut good = base; + good["files"].as_array_mut().unwrap().push(json!({ + "path": PROFILE_FILE, + "sha256": Sha256Hex::from_bytes(b"profile"), + "size": 7 + })); + assert!(parse(&good).is_ok()); +} + +#[test] +fn dependency_graph_requires_each_exact_signed_pin_in_dependency_first_order() { + let mut base = document(ComponentLayerKind::Runtime); + base["reference"] = json!("runtime/base@1.0.0"); + base["runtime_component"]["dependencies"] = json!([]); + let base = authenticated(&base); + let mut tool = document(ComponentLayerKind::Tool); + tool["reference"] = json!("tool/helper@2.0.0"); + tool["tool_component"]["dependencies"] = json!([]); + let tool = authenticated(&tool); + let mut root = document(ComponentLayerKind::Runtime); + root["runtime_component"]["dependencies"][0]["manifest_sha256"] = json!(base.manifest_sha256()); + root["runtime_component"]["dependencies"][1]["manifest_sha256"] = json!(tool.manifest_sha256()); + let root = authenticated(&root); + let roots = vec![ + PackPin::new( + root.manifest().reference().clone(), + root.manifest_sha256().clone(), + ) + .unwrap(), + ]; + let candidates = vec![root.clone(), base.clone(), tool.clone()]; + let ordered = resolve_graph(&roots, &candidates, "aarch64").unwrap(); + let names: Vec<_> = ordered + .iter() + .map(|component| component.manifest().reference().to_string()) + .collect(); + assert_eq!( + names, + [ + "runtime/base@1.0.0", + "tool/helper@2.0.0", + "runtime/python@1.2.3" + ] + ); + + assert!(matches!( + resolve_graph(&roots, &[root.clone(), base.clone()], "aarch64"), + Err(ComponentLayerResolveError::Missing(_)) + )); + assert!(matches!( + resolve_graph(&roots, &[root.clone(), base.clone(), base], "aarch64"), + Err(ComponentLayerResolveError::InvalidGraph) + )); + assert!(matches!( + resolve_graph( + &[roots[0].clone(), roots[0].clone()], + &candidates, + "aarch64" + ), + Err(ComponentLayerResolveError::InvalidGraph) + )); + assert!(matches!( + resolve_graph(&roots, &candidates, "x86_64"), + Err(ComponentLayerResolveError::Drift(_)) + )); + let wrong_pin = PackPin::new( + root.manifest().reference().clone(), + Sha256Hex::from_bytes(b"different"), + ) + .unwrap(); + assert!(matches!( + resolve_graph(&[wrong_pin], &candidates, "aarch64"), + Err(ComponentLayerResolveError::Drift(_)) + )); + let mut tampered = candidates; + if let RegistryComponentLayerManifest::RuntimeComponent { + runtime_component, .. + } = &mut tampered[0].manifest + { + runtime_component.dependencies[0] = PackPin::new( + tool.manifest().reference().clone(), + Sha256Hex::from_bytes(b"wrong dependency"), + ) + .unwrap(); + } + assert!(matches!( + resolve_graph(&roots, &tampered, "aarch64"), + Err(ComponentLayerResolveError::Drift(_)) + )); +} + +#[test] +fn publisher_and_lock_bind_original_bytes_not_reserialized_metadata() { + let value = document(ComponentLayerKind::Runtime); + let verified = authenticated(&value); + let bytes = serde_json::to_vec(&value).unwrap(); + assert_eq!(verified.manifest_bytes(), bytes); + assert_eq!(verified.signature_bundle(), b"signed"); + assert_eq!(verified.manifest_sha256(), &Sha256Hex::from_bytes(&bytes)); + let policy = publisher_policy(); + verified + .reverify_publisher_with(&policy, accept_signature) + .unwrap(); + assert!( + verified + .reverify_publisher_with( + &RegistryPackPublisherPolicy::new(vec![]).unwrap(), + accept_signature + ) + .is_err() + ); + let reference = verified.manifest().reference(); + let pin = PackPin::new(reference.clone(), verified.manifest_sha256().clone()).unwrap(); + let lock = PackLockfile::new(vec![pin]).unwrap(); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(reference, &bytes, b"unsigned", &lock, &policy), + reject_signature + ) + .is_err() + ); + let mut changed = bytes.clone(); + changed.push(b' '); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(reference, &changed, b"signed", &lock, &policy), + accept_signature + ) + .is_err() + ); + let other: PackReference = "runtime/other@1.2.3".parse().unwrap(); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(&other, &bytes, b"signed", &lock, &policy), + accept_signature + ) + .is_err() + ); +} + +#[test] +fn signed_current_feed_revokes_identity_digest_and_expired_document() { + use crate::packs::KeylessTrust; + use crate::registry_pack_revocation::{ + RegistryPackRevocationError, verify_registry_pack_revocations_with, + }; + + let verified = authenticated(&document(ComponentLayerKind::Tool)); + let now = chrono::Utc::now(); + let trust = KeylessTrust { + issuer: "test issuer".into(), + accepted_identities: vec!["test feed signer".into()], + }; + for (identities, digests, revoked) in [ + (Vec::new(), Vec::new(), false), + (vec![verified.signer().identity.clone()], Vec::new(), true), + (Vec::new(), vec![verified.manifest_sha256().clone()], true), + ] { + let document = json!({ + "schema_version": 1, + "sequence": 1, + "issued_at": now - chrono::Duration::hours(1), + "not_after": now + chrono::Duration::hours(1), + "revoked_identities": identities, + "revoked_manifests": digests, + }); + let feed = verify_registry_pack_revocations_with( + &serde_json::to_vec(&document).unwrap(), + b"signed feed", + &trust, + now, + None, + |_, _, _| Ok(()), + ) + .unwrap(); + assert_eq!( + feed.check_verified_pack_at(&verified, now).is_err(), + revoked + ); + assert!(matches!( + feed.check_verified_pack_at(&verified, now + chrono::Duration::hours(2)), + Err(RegistryPackRevocationError::Expired) + )); + } +} + +#[test] +fn private_inputs_pin_archive_and_policy_and_refuse_mutation() { + let verified = authenticated(&document(ComponentLayerKind::Runtime)); + let directory = tempfile::tempdir().unwrap(); + let archive = directory.path().join("archive.tar"); + let payload = directory.path().join("payload"); + std::fs::create_dir_all(payload.join("pack")).unwrap(); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(payload.join(GROUP_FILE), POLICY).unwrap(); + + std::fs::write(&archive, b"bad").unwrap(); + assert!(verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(&archive, b"component archivE").unwrap(); + assert!(matches!( + verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())), + Err(ComponentLayerInputError::Digest) + )); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(payload.join(GROUP_FILE), b"wrong policy...\n").unwrap(); + assert!(verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(payload.join(GROUP_FILE), POLICY).unwrap(); + let held = verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).unwrap(); + std::fs::write(&archive, b"changed original").unwrap(); + std::fs::write(payload.join(GROUP_FILE), b"changed original").unwrap(); + assert!(held.reverify_bytes().is_ok()); + assert_eq!(held.group(), Some(POLICY)); + let mut snapshot = held.snapshot.reopen().unwrap(); + let mut bytes = Vec::new(); + snapshot.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, ARCHIVE); + std::fs::write(held.snapshot.path(), b"tampered private").unwrap(); + assert!(matches!( + held.reverify_bytes(), + Err(ComponentLayerInputError::Digest | ComponentLayerInputError::Size) + )); +} diff --git a/crates/mvm-core/src/registry_pack/dispatch.rs b/crates/mvm-core/src/registry_pack/dispatch.rs index ad1d8ac017..4971f4a01f 100644 --- a/crates/mvm-core/src/registry_pack/dispatch.rs +++ b/crates/mvm-core/src/registry_pack/dispatch.rs @@ -1,6 +1,10 @@ //! Authenticated manifest dispatch. Bundle metadata is not bundle admission. use super::application::{RegistryApplicationManifest, parse_application_manifest}; +use super::application_component::{ + RegistryApplicationComponentManifest, parse_application_component_manifest, +}; +use super::component_layer::{VerifiedRegistryComponentLayer, finish_component_layer}; use super::runtime_layer::{RegistryRuntimeLayerManifest, parse_runtime_layer_manifest}; use super::*; @@ -76,10 +80,46 @@ impl VerifiedRegistryRuntimeLayer { } } +/// Publisher-authenticated component metadata, not an executable application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRegistryApplicationComponent { + pub(super) manifest: RegistryApplicationComponentManifest, + pub(super) manifest_bytes: Vec, + pub(super) signature_bundle: Vec, + pub(super) manifest_sha256: Sha256Hex, + pub(super) signer: VerifiedSigner, +} + +impl VerifiedRegistryApplicationComponent { + /// Strictly parsed, publisher-authenticated descriptor. + pub fn manifest(&self) -> &RegistryApplicationComponentManifest { + &self.manifest + } + + /// Exact signed descriptor bytes. + pub fn manifest_bytes(&self) -> &[u8] { + &self.manifest_bytes + } + + /// Detached publisher signature evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// Require the current signed registry revocation feed before use. + pub fn check_current_revocations( + &self, + ) -> Result<(), crate::registry_pack_store::RegistryPackStoreError> { + crate::registry_pack_store::check_application_revocations(self) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum VerifiedRegistryManifest { Legacy(VerifiedRegistryPack), Application(VerifiedRegistryApplication), + ApplicationComponent(VerifiedRegistryApplicationComponent), + ComponentLayer(VerifiedRegistryComponentLayer), RuntimeLayer(VerifiedRegistryRuntimeLayer), } @@ -143,12 +183,46 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryRuntimeLayer { } } +impl sealed::Sealed for VerifiedRegistryApplicationComponent {} +impl AuthenticatedRegistryManifest for VerifiedRegistryApplicationComponent { + fn reference(&self) -> &PackReference { + &self.manifest.reference + } + fn manifest_sha256(&self) -> &Sha256Hex { + &self.manifest_sha256 + } + fn signer(&self) -> &VerifiedSigner { + &self.signer + } + fn requires_operator_revocations(&self) -> bool { + true + } +} + +impl sealed::Sealed for VerifiedRegistryComponentLayer {} +impl AuthenticatedRegistryManifest for VerifiedRegistryComponentLayer { + fn reference(&self) -> &PackReference { + self.manifest().reference() + } + fn manifest_sha256(&self) -> &Sha256Hex { + self.manifest_sha256() + } + fn signer(&self) -> &VerifiedSigner { + self.signer() + } + fn requires_operator_revocations(&self) -> bool { + true + } +} + impl sealed::Sealed for VerifiedRegistryManifest {} impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { fn reference(&self) -> &PackReference { match self { Self::Legacy(pack) => pack.reference(), Self::Application(application) => application.reference(), + Self::ApplicationComponent(component) => component.reference(), + Self::ComponentLayer(component) => component.reference(), Self::RuntimeLayer(layer) => layer.reference(), } } @@ -157,6 +231,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.manifest_sha256(), Self::Application(application) => application.manifest_sha256(), + Self::ApplicationComponent(component) => component.manifest_sha256(), + Self::ComponentLayer(component) => component.manifest_sha256(), Self::RuntimeLayer(layer) => layer.manifest_sha256(), } } @@ -165,6 +241,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.signer(), Self::Application(application) => application.signer(), + Self::ApplicationComponent(component) => component.signer(), + Self::ComponentLayer(component) => component.signer(), Self::RuntimeLayer(layer) => layer.signer(), } } @@ -173,6 +251,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.requires_operator_revocations(), Self::Application(application) => application.requires_operator_revocations(), + Self::ApplicationComponent(component) => component.requires_operator_revocations(), + Self::ComponentLayer(component) => component.requires_operator_revocations(), Self::RuntimeLayer(layer) => layer.requires_operator_revocations(), } } @@ -203,6 +283,20 @@ pub(super) fn verify_with( ) } +/// Verify a component's pinned bytes and namespace publisher, without +/// admitting its archive or policy. The public entrypoint also checks revocations. +pub(super) fn verify_component_with( + request: &RegistryPackVerification<'_>, + check: RegistryPackSignatureChecker, +) -> Result { + match verify_with(request, check)? { + VerifiedRegistryManifest::ApplicationComponent(component) => Ok(component), + _ => Err(RegistryPackVerificationError::ManifestParse( + "pinned pack is not a portable application component".into(), + )), + } +} + /// First adoption authenticates the publisher; it does not persist a pin. pub fn adopt_registry_manifest( request: &PackAdoption<'_>, @@ -301,6 +395,40 @@ pub(super) fn adopt_with( }, )) } + (3, Some("application_component")) => { + let manifest = parse_application_component_manifest(request.manifest_bytes)?; + if manifest.reference.coordinate != request.requested.coordinate + || request + .requested + .version + .as_ref() + .is_some_and(|v| Some(v) != manifest.reference.version.as_ref()) + { + return Err(RegistryPackVerificationError::ManifestReferenceMismatch { + manifest: manifest.reference.to_string(), + locked: request.requested.to_string(), + }); + } + let manifest_sha256 = Sha256Hex::from_bytes(request.manifest_bytes); + historical::check_legacy_manifest_policy( + request.publisher_policy, + &signer.identity, + &manifest.reference, + &manifest_sha256, + )?; + Ok(VerifiedRegistryManifest::ApplicationComponent( + VerifiedRegistryApplicationComponent { + manifest, + manifest_sha256, + signer, + manifest_bytes: request.manifest_bytes.to_vec(), + signature_bundle: request.signature_bundle.to_vec(), + }, + )) + } + (4, Some("runtime_component" | "tool_component")) => { + finish_component_layer(request, signer).map(VerifiedRegistryManifest::ComponentLayer) + } _ => Err(RegistryPackVerificationError::ManifestParse( "unsupported registry manifest kind/schema pair".to_string(), )), diff --git a/crates/mvm-core/src/registry_pack_store.rs b/crates/mvm-core/src/registry_pack_store.rs index 49beb43328..b17d452a76 100644 --- a/crates/mvm-core/src/registry_pack_store.rs +++ b/crates/mvm-core/src/registry_pack_store.rs @@ -957,6 +957,20 @@ pub(crate) fn open_installed_registry_pack_with( } .into()) } + VerifiedRegistryManifest::ApplicationComponent(component) => { + Err(RegistryPackVerificationError::UnsupportedManifestSchema { + got: component.manifest().schema_version, + expected: crate::registry_pack::REGISTRY_PACK_MANIFEST_SCHEMA_VERSION, + } + .into()) + } + VerifiedRegistryManifest::ComponentLayer(_) => { + Err(RegistryPackVerificationError::UnsupportedManifestSchema { + got: 4, + expected: crate::registry_pack::REGISTRY_PACK_MANIFEST_SCHEMA_VERSION, + } + .into()) + } VerifiedRegistryManifest::RuntimeLayer(layer) => { Err(RegistryPackVerificationError::UnsupportedManifestSchema { got: layer.manifest().schema_version, @@ -1030,6 +1044,18 @@ fn open_installed_registry_manifest_with_checks( VerifiedRegistryManifest::Application(application) => { check_application(application)?; } + VerifiedRegistryManifest::ApplicationComponent(_) => { + return Err(RegistryPackVerificationError::ManifestParse( + "application component install requires verified archive custody".to_string(), + ) + .into()); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + return Err(RegistryPackVerificationError::ManifestParse( + "runtime/tool component install requires verified archive custody".to_string(), + ) + .into()); + } VerifiedRegistryManifest::RuntimeLayer(_) => { return Err(RegistryPackVerificationError::ManifestParse( "runtime layer install requires verified archive custody".to_string(), diff --git a/crates/mvm-fs/README.md b/crates/mvm-fs/README.md index bd92288f32..ef2be6ef97 100644 --- a/crates/mvm-fs/README.md +++ b/crates/mvm-fs/README.md @@ -42,15 +42,16 @@ on read, so warm-pool state is addressed by content rather than mutable names. | `clone` | Reflink/copy-on-write cloning | | `hash` / `manifest` / `snapshot_store` | Content identity and durable snapshots; `manifest` is the chunked v2 form that lets a CoW-chain child reuse its parent's chunk digests where the kernel proves the bytes unchanged | | `extension_image` / `sdk_sidecar` | Auxiliary guest image assembly | -| `trusted_snapshot` | Platform-backed trusted snapshot support | +| `trusted_snapshot` | Platform-backed snapshot support; Linux sealed-file construction is a budgeted building block, not an admitted warm claim | ## Security boundaries Registry and archive input is untrusted. Paths are normalized before writes, digests are checked before content is trusted, allocations are capped, and special files or traversal attempts are rejected. The crate denies unsafe code -except for small reviewed platform calls in the cloning and extent-map -implementations. +except for small reviewed platform calls in cloning, extent-map, APFS snapshot +and Linux memfd sealing modules. A sealed input's digest and budget do not +authorize a launch; its owner must retain custody through VMM termination. ## Developing diff --git a/crates/mvm-fs/src/lib.rs b/crates/mvm-fs/src/lib.rs index 63a41f30e1..a7ea1fa7a9 100644 --- a/crates/mvm-fs/src/lib.rs +++ b/crates/mvm-fs/src/lib.rs @@ -27,8 +27,9 @@ //! The crate-level lint is `deny(unsafe_code)` rather than `forbid` so that //! the [`clone`] module can use the small, platform-specific unsafe blocks //! required to invoke `clonefile(2)` and `FICLONE`, and so the crate-private -//! `extent` module can issue the Linux `FIEMAP` ioctl. Everywhere else in -//! this crate remains unsafe-free. +//! `extent` module can issue the Linux `FIEMAP` ioctl. The Linux sealed boot +//! file module also calls `memfd_create`, `fcntl`, and `sysconf`; elsewhere +//! the crate remains unsafe-free. #[cfg(unix)] pub mod clone; diff --git a/crates/mvm-fs/src/trusted_snapshot.rs b/crates/mvm-fs/src/trusted_snapshot.rs index 6d514d6583..d00b8dfa9a 100644 --- a/crates/mvm-fs/src/trusted_snapshot.rs +++ b/crates/mvm-fs/src/trusted_snapshot.rs @@ -18,6 +18,10 @@ use std::path::Path; use crate::clone::CloneStrategy; use crate::snapshot_store::SnapshotId; +/// Linux-only sealed-file building block; it grants no launch authority. +#[cfg(target_os = "linux")] +pub mod sealed_boot_file; + /// A snapshot provider whose sealed read view is immutable to the claim path. /// /// Implementations must complete all expensive hashing and publication work in diff --git a/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs b/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs new file mode 100644 index 0000000000..bad67ad6f1 --- /dev/null +++ b/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs @@ -0,0 +1,262 @@ +//! Budgeted Linux memfds for immutable boot input bytes. +//! +//! Sealing an input proves byte immutability, not its signature, admission, +//! VMM custody or permission to reuse a warm parent. + +#![allow(unsafe_code)] + +use std::fs::File; +use std::io::{self, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::FileExt; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; + +#[derive(Debug, Default)] +struct Usage { + bytes: u64, + files: usize, +} + +/// Page-rounded memfd data and file-count limits shared by one owner. +#[derive(Debug, Clone)] +pub struct SealedBootBudget { + max_bytes: u64, + max_files: usize, + usage: Arc>, +} + +impl SealedBootBudget { + /// Set the maximum charged data bytes and owned files. + pub fn new(max_bytes: u64, max_files: usize) -> Self { + Self { + max_bytes, + max_files, + usage: Arc::new(Mutex::new(Usage::default())), + } + } + + fn reserve(&self, bytes: u64) -> io::Result { + // SAFETY: sysconf only queries the host page size. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page <= 0 { + return Err(io::Error::last_os_error()); + } + let page = page as u64; + let charged = bytes + .checked_add(page - 1) + .map(|rounded| rounded / page * page) + .ok_or_else(|| io::Error::other("sealed boot input size overflow"))?; + let mut usage = self + .usage + .lock() + .map_err(|_| io::Error::other("boot budget poisoned"))?; + if bytes == 0 + || usage + .bytes + .checked_add(charged) + .is_none_or(|sum| sum > self.max_bytes) + || usage.files >= self.max_files + { + return Err(io::Error::other( + "sealed boot input exceeds resident budget", + )); + } + usage.bytes += charged; + usage.files += 1; + Ok(Reservation { + usage: Arc::clone(&self.usage), + bytes: charged, + }) + } + + /// Copy one already authenticated file into a sealed, inode-independent view. + /// + /// The digest expectation must come from an independently verified signed + /// closure; a caller-supplied digest alone does not authorize a boot. + /// + /// # Errors + /// + /// Refuses size/digest mismatch, non-files, quota exhaustion or failed + /// kernel sealing without exposing an unsealed descriptor. + pub fn seal( + &self, + source: &File, + expected_size: u64, + expected_sha256: &str, + ) -> io::Result { + let mut expected = [0u8; 32]; + hex::decode_to_slice(expected_sha256, &mut expected) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SHA-256 digest"))?; + let metadata = source.metadata()?; + if !metadata.is_file() || metadata.len() != expected_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "boot input is not a regular file of the expected size", + )); + } + let reservation = self.reserve(expected_size)?; + // SAFETY: `memfd_create` receives a constant NUL-terminated name; + // the returned descriptor is owned only when the syscall succeeds. + let fd = unsafe { + libc::memfd_create( + c"mvm-sealed-boot".as_ptr(), + libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: successful memfd_create returned a fresh owned descriptor. + let mut file = unsafe { File::from_raw_fd(fd) }; + let mut hash = Sha256::new(); + let mut offset = 0; + let mut buffer = [0u8; 64 * 1024]; + while offset < expected_size { + let remaining = (expected_size - offset).min(buffer.len() as u64) as usize; + let read = match source.read_at(&mut buffer[..remaining], offset) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot input shrank", + )); + } + hash.update(&buffer[..read]); + file.write_all(&buffer[..read])?; + offset += read as u64; + } + let actual: [u8; 32] = hash.finalize().into(); + if actual != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "boot input digest changed", + )); + } + let seals = + libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL; + // SAFETY: these calls only seal and inspect the owned memfd. + let applied = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) }; + if applied != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: F_GET_SEALS reads the seal mask from the same owned memfd. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GET_SEALS) } != seals { + return Err(io::Error::other( + "sealed boot input lost its required seals", + )); + } + Ok(SealedBootFile { + file, + _reservation: reservation, + sha256: expected, + }) + } +} + +#[derive(Debug)] +struct Reservation { + usage: Arc>, + bytes: u64, +} + +impl Drop for Reservation { + fn drop(&mut self) { + if let Ok(mut usage) = self.usage.lock() { + usage.bytes -= self.bytes; + usage.files -= 1; + } + } +} + +/// An immutable file and its resident reservation, not an admitted VM lease. +#[derive(Debug)] +#[must_use = "retain the sealed file until every VMM inheriting it has exited"] +pub struct SealedBootFile { + file: File, + _reservation: Reservation, + sha256: [u8; 32], +} + +impl SealedBootFile { + /// Borrow the original while retaining this owner through the VMM's exit. + /// + /// Duplicating the descriptor without retaining this value would release + /// its memory reservation while another process still holds those pages. + pub fn file(&self) -> &File { + &self.file + } + + /// Return the digest verified while copying, not a publisher identity. + pub fn sha256(&self) -> [u8; 32] { + self.sha256 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sealed_bytes_refuse_mutation_and_release_the_reservation() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("kernel"); + std::fs::write(&source, b"authenticated-by-caller").unwrap(); + let input = File::open(&source).unwrap(); + let digest = hex::encode(Sha256::digest(b"authenticated-by-caller")); + // SAFETY: sysconf only queries the page size for this test's budget. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; + let budget = SealedBootBudget::new(page, 1); + let sealed = budget.seal(&input, 23, &digest).unwrap(); + assert_eq!( + sealed.sha256(), + <[u8; 32]>::from(Sha256::digest(b"authenticated-by-caller")) + ); + assert!(budget.seal(&input, 23, &digest).is_err()); + assert_eq!( + sealed + .file() + .write_at(b"tamper", 0) + .unwrap_err() + .raw_os_error(), + Some(libc::EPERM) + ); + std::fs::write(&source, b"changed-after-sealing").unwrap(); + let mut bytes = [0u8; 23]; + sealed.file().read_exact_at(&mut bytes, 0).unwrap(); + assert_eq!(&bytes, b"authenticated-by-caller"); + drop(sealed); + std::fs::write(&source, b"authenticated-by-caller").unwrap(); + assert!(budget.seal(&input, 23, &digest).is_ok()); + } + + #[test] + fn bad_input_cannot_allocate_or_publish_a_sealed_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("input"); + std::fs::write(&path, b"valid").unwrap(); + let input = File::open(&path).unwrap(); + let digest = hex::encode(Sha256::digest(b"valid")); + // SAFETY: sysconf only queries the page size for this test's budget. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; + let budget = SealedBootBudget::new(page, 1); + assert!( + SealedBootBudget::new(page - 1, 1) + .seal(&input, 5, &digest) + .is_err() + ); + assert!(budget.seal(&input, 4, &digest).is_err()); + assert!(budget.seal(&input, 5, &"0".repeat(64)).is_err()); + assert!(budget.seal(&input, 5, "not-a-digest").is_err()); + assert!( + budget + .seal(&File::open(tmp.path()).unwrap(), 5, &digest) + .is_err() + ); + let sealed = budget.seal(&input, 5, &digest).unwrap(); + assert_eq!(sealed.sha256(), <[u8; 32]>::from(Sha256::digest(b"valid"))); + } +} diff --git a/crates/mvm-hostd/Cargo.toml b/crates/mvm-hostd/Cargo.toml index cf71a28bb7..151911da72 100644 --- a/crates/mvm-hostd/Cargo.toml +++ b/crates/mvm-hostd/Cargo.toml @@ -212,6 +212,7 @@ default = [] # macOS-only protected caller opt-in; other platforms return Unsupported. native-caller-identity = ["dep:core-foundation", "dep:security-framework"] manifest-verify = ["mvm-core/manifest-verify"] +pr-test-guest-runtime = ["mvm-runtime/pr-test-guest-runtime", "mvm-build/pr-test-guest-runtime"] # Build the hermetic legacy-L3/FlowMux comparison producer. Never enabled by # a production binary; it exposes only a high-level loopback fixture. network-perf = [] diff --git a/crates/mvm-hostd/src/health_probe.rs b/crates/mvm-hostd/src/health_probe.rs index 02ed495780..8d0d97b859 100644 --- a/crates/mvm-hostd/src/health_probe.rs +++ b/crates/mvm-hostd/src/health_probe.rs @@ -566,6 +566,7 @@ mod tests { runtime_pack: false, registry_pack_image: None, resolved_digest: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-hostd/src/operator_admitted_trial_booter.rs b/crates/mvm-hostd/src/operator_admitted_trial_booter.rs index 6e0cb0696e..3def9d3359 100644 --- a/crates/mvm-hostd/src/operator_admitted_trial_booter.rs +++ b/crates/mvm-hostd/src/operator_admitted_trial_booter.rs @@ -660,6 +660,10 @@ pub(crate) fn verify_assurance_guest_protocol( Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::Source(_)) => { anyhow::bail!("assurance requires a released runtime, not a source runtime"); } + #[cfg(feature = "pr-test-guest-runtime")] + Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::PrTest(_)) => { + anyhow::bail!("assurance refuses PR test runtime provenance"); + } Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::LegacyBundle(_)) | Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::LegacyPortable(_)) => Ok(None), None => anyhow::bail!("assurance requires authenticated guest runtime provenance"), diff --git a/crates/mvm-hostd/src/plan_admission.rs b/crates/mvm-hostd/src/plan_admission.rs index 4998565b67..57b554f32b 100644 --- a/crates/mvm-hostd/src/plan_admission.rs +++ b/crates/mvm-hostd/src/plan_admission.rs @@ -97,14 +97,17 @@ mod composition; mod display_grants; mod final_gates; mod host_gates; +mod local_development; mod nonce_ledger; mod registry_pack; +mod shares; mod verb_grant_sidecar; use assurance_runtime::{AssuranceRuntimeBinding, verify_selected_runtime}; use host_gates::{admit_host_interface, admit_within_host_budget}; pub use nonce_ledger::InMemoryNonceLedger; use registry_pack::verify_registry_pack_assets; +pub use shares::enforce_admitted_shares; use verb_grant_sidecar::mint_verb_grant_sidecar; /// The external plan signers this host trusts, from operator config. @@ -566,6 +569,18 @@ fn finish_admission(parts: VerifiedAdmission<'_>) -> Result { .context("canonical kernel admission requires actual backend")?, &mvm_build::stage0_kernel::current_image_set_protocol_support(), ) + } else if posture.variant == Variant::Dev { + if let Some(host_key) = mvm_core::plan::bundle::existing_local_build_key()? { + mvm_core::plan::bundle::verify_development_plan_bundle( + pin, + ctx.resolver, + ctx.trust, + &mvm_core::plan::bundle::key_id_from_pubkey(&host_key), + config, + ) + } else { + verify_plan_bundle(pin, ctx.resolver, ctx.trust) + } } else { verify_plan_bundle(pin, ctx.resolver, ctx.trust) } @@ -573,6 +588,7 @@ fn finish_admission(parts: VerifiedAdmission<'_>) -> Result { verify_plan_bundle(pin, ctx.resolver, ctx.trust) } .with_context(|| format!("bundle re-verify for pin {}", pin.bundle_sha256))?; + local_development::verify(&verified, &signed, &signer_id, &posture, &verified_bundle)?; bundle_environment::verify_environment(&verified, &verified_bundle.manifest, posture)?; composition::verify(&verified, &verified_bundle)?; if !verified_bundle.embedded_image_sets.is_empty() { @@ -1242,89 +1258,6 @@ pub fn stash_plan_and_mint_verb_grant( mint_verb_grant_sidecar(plan_json, &cfg.name, &state_dir) } -/// Admission enforcement: every volume about to be attached must be -/// named in the verified `ExecutionPlan.shares`, with matching -/// host path, guest path, kind, ro/rw, and encryption. -/// -/// On the local `mvmctl machine run` path the CLI builds both the signed -/// plan and the launch config from one source, so this is self-consistent -/// by construction — but it **fails closed** if they ever diverge (a CLI -/// bug, or a future caller that hands a config the plan didn't authorize). -/// It's the trust-boundary hook: no host-fs grant reaches a guest unless -/// the signed plan admitted it (claim 1 / claim 8). The supervisor + -/// mvmd enforcement mirror this check against the same `plan.shares`. -pub fn enforce_admitted_shares( - volumes: &[mvm_core::vm_backend::VmVolume], - plan: &ExecutionPlan, -) -> Result<()> { - for v in volumes { - let want_kind = if v.materialized_image.is_some() { - mvm_core::plan::ShareKind::DirShare - } else { - mvm_core::plan::ShareKind::Disk - }; - let admitted = plan.shares.iter().find(|g| { - g.host_path == v.host - && g.guest_path == v.guest - && g.kind == want_kind - && g.read_only == v.read_only - && g.encrypted == v.encrypted - }); - let Some(grant) = admitted else { - anyhow::bail!( - "refusing to attach volume '{}' -> '{}' ({}): it is not named in the signed \ - ExecutionPlan's admitted shares — every host-fs grant must be admitted (claim 1).", - v.host, - v.guest, - if v.read_only { "ro" } else { "rw" }, - ); - }; - // Content identity (claim 19): a grant that recorded what the shared - // tree hashed to at admission must still hash the same at attach - // time. The admitted digest is inside the signed plan, so this closes - // the window between admission and boot where the host directory - // could change under the granted path — the guest would otherwise - // receive bytes the plan never vouched for. - if let Some(expected) = &grant.content_sha256 { - // Canonicalize so a symlinked host dir (macOS `/tmp`) hashes - // the same tree admission pinned, whichever alias names it. - let actual = if let Some(image) = &v.materialized_image { - mvm_core::crypto::image_verify::sha256_file(std::path::Path::new(image)).map_err( - |e| { - anyhow::anyhow!( - "refusing to attach volume '{}' -> '{}': cannot re-hash frozen image \ - '{}': {e}", - v.host, - v.guest, - image, - ) - }, - )? - } else { - let resolved = std::fs::canonicalize(&v.host)?; - mvm_fs::hash::hash_source(&resolved).map_err(|e| { - anyhow::anyhow!( - "refusing to attach volume '{}' -> '{}': cannot re-hash the granted \ - directory to check its admitted content identity: {e}", - v.host, - v.guest, - ) - })? - }; - if actual != *expected { - anyhow::bail!( - "refusing to attach volume '{}' -> '{}': the directory changed after \ - admission — admitted content identity {expected}, now {actual}. Re-admit \ - the plan against the current tree.", - v.host, - v.guest, - ); - } - } - } - Ok(()) -} - /// Typed reasons the selected backend cannot carry the native SDK sidecar. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum SdkSidecarBackendCompatibilityError { diff --git a/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs b/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs index f2a5257cf6..9f2b5d382e 100644 --- a/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs +++ b/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs @@ -115,6 +115,11 @@ impl RunPosture<'_> { let Some(config) = self.runtime_config else { return Ok(()); }; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + config.pr_test_runtime_proof.is_none() || self.variant == super::Variant::Dev, + "PR test runtime is not admitted under production posture" + ); verify_selected_runtime(config) } @@ -146,6 +151,33 @@ mod tests { use super::super::{BackendKind, Variant}; use super::*; + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn production_posture_refuses_pr_test_runtime_before_archive_access() { + let config = VmStartConfig { + pr_test_runtime_proof: Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: mvm_core::protocol::runtime_release::RuntimeReleaseProof { + archive: "/nonexistent/pr/archive".into(), + checksum: "/nonexistent/pr/checksum".into(), + signature_bundle: "/nonexistent/pr/bundle".into(), + archive_sha256: "a".repeat(64), + version: env!("CARGO_PKG_VERSION").into(), + arch: mvm_core::arch::GuestArch::host(), + }, + pull_request: 42, + }), + ..Default::default() + }; + let error = RunPosture::on_backend(Variant::Prod, BackendKind::Firecracker) + .with_runtime(Some(&config)) + .verify_runtime() + .unwrap_err(); + assert!( + error.to_string().contains("production posture"), + "production refusal must precede archive access: {error:#}" + ); + } + #[test] fn runtime_gate_refuses_unverified_unsigned_request_with_forged_plan() { let config = VmStartConfig { diff --git a/crates/mvm-hostd/src/plan_admission/composition.rs b/crates/mvm-hostd/src/plan_admission/composition.rs index 9a2cc50980..7dd41a6b7f 100644 --- a/crates/mvm-hostd/src/plan_admission/composition.rs +++ b/crates/mvm-hostd/src/plan_admission/composition.rs @@ -62,9 +62,12 @@ pub(super) fn verify(plan: &ExecutionPlan, bundle: &VerifiedBundle) -> Result<() .iter() .find_map(|member| match member { BundleMember::CanonicalLinuxProof(proof) => Some(proof.manifest_artifact.as_str()), + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some(proof.manifest_artifact.as_str()) + } _ => None, }) - .context("composition lacks original signed Linux root")?; + .context("composition lacks selected Linux base evidence")?; let evidence = |name: &str| -> Result<&[u8]> { let artifact = bundle .manifest diff --git a/crates/mvm-hostd/src/plan_admission/local_development.rs b/crates/mvm-hostd/src/plan_admission/local_development.rs new file mode 100644 index 0000000000..b784b5dbe0 --- /dev/null +++ b/crates/mvm-hostd/src/plan_admission/local_development.rs @@ -0,0 +1,46 @@ +//! Same-host custody for a selected development base; never release authority. + +use anyhow::{Context, Result}; +use mvm_core::plan::bundle::{ArtifactRole, BundleMember, VerifiedBundle, key_id_from_pubkey}; +use mvm_core::plan::{ExecutionPlan, SignedExecutionPlan, Variant, verify_plan}; + +use super::{RunPosture, host_signer_id}; + +pub(super) fn verify( + plan: &ExecutionPlan, + signed: &SignedExecutionPlan, + signer_id: &str, + posture: &RunPosture<'_>, + bundle: &VerifiedBundle, +) -> Result<()> { + if !bundle + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + { + return Ok(()); + } + anyhow::ensure!( + posture.variant == Variant::Dev && signer_id == host_signer_id(), + "local development base refuses production or externally signed plans" + ); + let host = crate::audit::host_keypair::load_or_init()?; + verify_plan(signed, &[(signer_id, &host.verifying)]) + .context("local development plan must be signed on this host")?; + anyhow::ensure!( + bundle.key_id == key_id_from_pubkey(&host.verifying), + "local development bundle signer differs from this host" + ); + let kernel = bundle + .manifest + .find_by_role(&ArtifactRole::Kernel) + .context("local development kernel is missing")?; + anyhow::ensure!( + plan.environment + .as_ref() + .is_some_and(|environment| environment.kernel_sha256 == kernel.sha256), + "local development kernel differs from signed plan environment" + ); + Ok(()) +} diff --git a/crates/mvm-hostd/src/plan_admission/registry_pack.rs b/crates/mvm-hostd/src/plan_admission/registry_pack.rs index e4bb5d8101..2f2453f96f 100644 --- a/crates/mvm-hostd/src/plan_admission/registry_pack.rs +++ b/crates/mvm-hostd/src/plan_admission/registry_pack.rs @@ -300,6 +300,12 @@ fn finalize_registry_images( VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("a runtime layer cannot be used as a boot image"); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!("an application component cannot be used as a boot image"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("a runtime/tool component cannot be used as a boot image"); + } VerifiedRegistryManifest::Application(_) => { anyhow::ensure!( !built, diff --git a/crates/mvm-hostd/src/plan_admission/shares.rs b/crates/mvm-hostd/src/plan_admission/shares.rs new file mode 100644 index 0000000000..937edcaf19 --- /dev/null +++ b/crates/mvm-hostd/src/plan_admission/shares.rs @@ -0,0 +1,86 @@ +//! Share-attachment checks against the admitted plan. + +use super::*; + +/// Admission enforcement: every volume about to be attached must be +/// named in the verified `ExecutionPlan.shares`, with matching +/// host path, guest path, kind, ro/rw, and encryption. +/// +/// On the local `mvmctl machine run` path the CLI builds both the signed +/// plan and the launch config from one source, so this is self-consistent +/// by construction — but it **fails closed** if they ever diverge (a CLI +/// bug, or a future caller that hands a config the plan didn't authorize). +/// It's the trust-boundary hook: no host-fs grant reaches a guest unless +/// the signed plan admitted it (claim 1 / claim 8). The supervisor + +/// mvmd enforcement mirror this check against the same `plan.shares`. +pub fn enforce_admitted_shares( + volumes: &[mvm_core::vm_backend::VmVolume], + plan: &ExecutionPlan, +) -> Result<()> { + for v in volumes { + let want_kind = if v.materialized_image.is_some() { + mvm_core::plan::ShareKind::DirShare + } else { + mvm_core::plan::ShareKind::Disk + }; + let admitted = plan.shares.iter().find(|g| { + g.host_path == v.host + && g.guest_path == v.guest + && g.kind == want_kind + && g.read_only == v.read_only + && g.encrypted == v.encrypted + }); + let Some(grant) = admitted else { + anyhow::bail!( + "refusing to attach volume '{}' -> '{}' ({}): it is not named in the signed \ + ExecutionPlan's admitted shares — every host-fs grant must be admitted (claim 1).", + v.host, + v.guest, + if v.read_only { "ro" } else { "rw" }, + ); + }; + // Content identity (claim 19): a grant that recorded what the shared + // tree hashed to at admission must still hash the same at attach + // time. The admitted digest is inside the signed plan, so this closes + // the window between admission and boot where the host directory + // could change under the granted path — the guest would otherwise + // receive bytes the plan never vouched for. + if let Some(expected) = &grant.content_sha256 { + // Canonicalize so a symlinked host dir (macOS `/tmp`) hashes + // the same tree admission pinned, whichever alias names it. + let actual = if let Some(image) = &v.materialized_image { + mvm_core::crypto::image_verify::sha256_file(std::path::Path::new(image)).map_err( + |e| { + anyhow::anyhow!( + "refusing to attach volume '{}' -> '{}': cannot re-hash frozen image \ + '{}': {e}", + v.host, + v.guest, + image, + ) + }, + )? + } else { + let resolved = std::fs::canonicalize(&v.host)?; + mvm_fs::hash::hash_source(&resolved).map_err(|e| { + anyhow::anyhow!( + "refusing to attach volume '{}' -> '{}': cannot re-hash the granted \ + directory to check its admitted content identity: {e}", + v.host, + v.guest, + ) + })? + }; + if actual != *expected { + anyhow::bail!( + "refusing to attach volume '{}' -> '{}': the directory changed after \ + admission — admitted content identity {expected}, now {actual}. Re-admit \ + the plan against the current tree.", + v.host, + v.guest, + ); + } + } + } + Ok(()) +} diff --git a/crates/mvm-hostd/src/run.rs b/crates/mvm-hostd/src/run.rs index aebd4d5f68..502379fe35 100644 --- a/crates/mvm-hostd/src/run.rs +++ b/crates/mvm-hostd/src/run.rs @@ -190,6 +190,14 @@ pub(crate) fn attach_guest_runtime(config: &mut VmStartConfig, backend_name: &st if !mvm_runtime::universal_initramfs::KERNEL_BOOTING_HYPERVISORS.contains(&backend_name) { return Ok(()); } + #[cfg(feature = "pr-test-guest-runtime")] + if config.pr_test_runtime_proof.is_some() { + mvm_runtime::runtime_evidence::verify_runtime_for_launch( + config, + mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + )?; + return Ok(()); + } let mut selected = config.clone(); attach_runtime_overlay_from_cache(&mut selected, backend_name)?; let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()); diff --git a/crates/mvm-hostd/src/supervisor/caller_registration.rs b/crates/mvm-hostd/src/supervisor/caller_registration.rs index 83fb158877..28f6afb776 100644 --- a/crates/mvm-hostd/src/supervisor/caller_registration.rs +++ b/crates/mvm-hostd/src/supervisor/caller_registration.rs @@ -603,6 +603,12 @@ fn reopen_caller_image( VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("a runtime layer cannot be used as a caller boot image"); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!("an application component cannot be used as a caller boot image"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("a runtime/tool component cannot be used as a caller boot image"); + } VerifiedRegistryManifest::Application(_) => { use mvm_core::plan::bundle::{ ArtifactRole, BundleRegistry, FsBundleResolver, FsTrustStore, diff --git a/crates/mvm-hostlib/src/entrypoint.rs b/crates/mvm-hostlib/src/entrypoint.rs index 19ee6eafef..280ab5052d 100644 --- a/crates/mvm-hostlib/src/entrypoint.rs +++ b/crates/mvm-hostlib/src/entrypoint.rs @@ -206,6 +206,7 @@ impl EntrypointOps for LocalEntrypoint { memory_mib: spec.memory_mib, admission, portable_boot_assets: None, + transient: None, }, stdin: CallStdin::for_payload(spec.payload), timeout_secs: spec.timeout_secs, @@ -795,6 +796,7 @@ fn start_admitted(spec: StartSpec, admission: EntrypointAdmission) -> Result, +} + +/// Test-only runtime selection stored as source data, not launch authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalPackPrRuntime { + /// Directory holding the signed runtime archive and its signed checksum. + pub directory: PathBuf, + /// Pull request whose workflow identity signed the proof. + pub pull_request: u64, +} + +impl LocalPackBaseSource { + /// Record a local base and optional PR runtime as paths, not authority. + /// + /// # Errors + /// Refuses linked or non-regular base inputs and linked or missing runtime directories. + pub fn from_selected( + manifest: &Path, + kernel: &Path, + tar: &Path, + pr_runtime: Option, + ) -> Result { + for path in [manifest, kernel, tar] { + ensure!( + fs::symlink_metadata(path)?.file_type().is_file(), + "local pack base input must be a regular file: {}", + path.display() + ); + } + let pr_runtime = pr_runtime + .map(|mut proof| -> Result<_> { + ensure!( + proof.pull_request > 0 + && fs::symlink_metadata(&proof.directory)?.file_type().is_dir(), + "local pack PR runtime must be a real directory and name a pull request" + ); + proof.directory = fs::canonicalize(&proof.directory)?; + Ok(proof) + }) + .transpose()?; + Ok(Self { + manifest: fs::canonicalize(manifest)?, + kernel: fs::canonicalize(kernel)?, + tar: fs::canonicalize(tar)?, + pr_runtime, + }) + } +} + +/// Data-only identity of a locally composed pack; never an installed-bundle authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalPackSource { + /// Absolute recipe path to re-read on every start. + pub recipe: PathBuf, + /// Exact authored recipe selected when the machine was created. + pub recipe_sha256: mvm_core::packs::Sha256Hex, + /// Verified, host-signed cache entry selected at creation. + pub archive_sha256: mvm_core::packs::Sha256Hex, + /// Optional local base and PR proof selected at creation; reverified at start. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option, +} + +impl LocalPackSource { + /// Record the authored recipe and the already-verified archive's identity as data. + /// + /// # Errors + /// Refuses a linked, non-regular or oversized recipe. + pub fn from_prepared( + recipe: &Path, + archive_sha256: mvm_core::packs::Sha256Hex, + ) -> Result { + let recipe_sha256 = local_recipe_digest(recipe)?; + Ok(Self { + recipe: fs::canonicalize(recipe).context("resolving local pack recipe")?, + recipe_sha256, + archive_sha256, + base: None, + }) + } + + /// Check the authored bytes still match this machine's data-only recipe pin. + /// + /// # Errors + /// Refuses replacement, changed bytes or non-regular input. + pub fn verify_recipe(&self) -> Result<()> { + ensure!( + self.recipe.is_absolute(), + "local pack recipe must be absolute" + ); + ensure!( + local_recipe_digest(&self.recipe)? == self.recipe_sha256, + "local pack recipe differs from the recorded machine source" + ); + if let Some(base) = &self.base { + ensure!( + [&base.manifest, &base.kernel, &base.tar] + .iter() + .all(|path| path.is_absolute()), + "local pack base paths must be absolute" + ); + if let Some(proof) = &base.pr_runtime { + ensure!( + proof.directory.is_absolute() && proof.pull_request > 0, + "local pack PR runtime source must be absolute and name a pull request" + ); + } + } + Ok(()) + } +} + +fn local_recipe_digest(recipe: &Path) -> Result { + let metadata = fs::symlink_metadata(recipe).context("inspecting local pack recipe")?; + ensure!( + metadata.file_type().is_file() && metadata.len() <= MAX_LOCAL_PACK_RECIPE_BYTES, + "local pack recipe must be a regular file no larger than 64 KiB" + ); + Ok(mvm_core::packs::Sha256Hex::from_bytes( + &fs::read(recipe).context("reading local pack recipe")?, + )) +} + /// Declarative persistent machine spec. Runtime state lives elsewhere. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -55,6 +195,9 @@ pub struct MachineSpec { /// reopens it under current publisher trust before signing the plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub registry_pack_image: Option, + /// A recipe and cache identity, never publisher enrollment or plan admission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_pack: Option, pub net: bool, pub allow_host: Vec, /// Peer routes this machine may dial (`NAME:PORT=ADDR:PORT`), persisted so @@ -281,6 +424,7 @@ pub fn machine_config_matches(a: &MachineSpec, b: &MachineSpec) -> bool { && a.deployment == b.deployment && a.runtime_pack == b.runtime_pack && a.registry_pack_image == b.registry_pack_image + && a.local_pack == b.local_pack && a.net == b.net && same_allow_host(&a.allow_host, &b.allow_host) && a.peer == b.peer @@ -311,6 +455,7 @@ pub fn machine_config_diff(current: &MachineSpec, desired: &MachineSpec) -> Stri || current.deployment != desired.deployment || current.runtime_pack != desired.runtime_pack || current.registry_pack_image != desired.registry_pack_image + || current.local_pack != desired.local_pack || current.workload_dir != desired.workload_dir { changed.push("source"); @@ -504,6 +649,7 @@ mod tests { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), @@ -548,9 +694,117 @@ mod tests { serde_json::from_str(&legacy).expect("a pre-grants spec still loads"); assert_eq!(spec.caller_commitment, None); assert_eq!(spec.grants, None); + assert_eq!(spec.local_pack, None); // And a spec that granted nothing must not start emitting the key, so // rewriting an old spec does not gratuitously change its bytes. assert!(!serde_json::to_string(&spec).unwrap().contains("grants")); + assert!(!serde_json::to_string(&spec).unwrap().contains("local_pack")); + } + + #[test] + fn local_pack_source_round_trips_as_data_and_changes_trigger_reconciliation() { + let mut current = spec_fixture("packed"); + current.image = None; + current.profile = "dev".into(); + current.local_pack = Some(LocalPackSource { + recipe: std::path::PathBuf::from("/project/pack.toml"), + recipe_sha256: mvm_core::packs::Sha256Hex::from_bytes(b"recipe"), + archive_sha256: mvm_core::packs::Sha256Hex::from_bytes(b"archive"), + base: None, + }); + let stored = serde_json::to_vec(¤t).expect("serialize local source"); + let round_trip: MachineSpec = + serde_json::from_slice(&stored).expect("deserialize local source"); + assert_eq!(round_trip, current); + assert!(round_trip.manifest.is_none()); + assert!(round_trip.registry_pack_image.is_none()); + + let mut changed = round_trip.clone(); + changed + .local_pack + .as_mut() + .expect("local source") + .recipe_sha256 = mvm_core::packs::Sha256Hex::from_bytes(b"changed recipe"); + assert!(!machine_config_matches(&round_trip, &changed)); + assert_eq!(machine_config_diff(&round_trip, &changed), "source"); + + let mut selected = round_trip.clone(); + selected.local_pack.as_mut().expect("source").base = Some(LocalPackBaseSource { + manifest: "/signed/image-set.json".into(), + kernel: "/signed/vmlinux".into(), + tar: "/signed/rootfs.tar.gz".into(), + pr_runtime: Some(LocalPackPrRuntime { + directory: "/signed/guest-runtime".into(), + pull_request: 5326, + }), + }); + let stored = serde_json::to_vec(&selected).expect("serialize selected base"); + let restored: MachineSpec = serde_json::from_slice(&stored).expect("restore selected base"); + assert_eq!(restored, selected); + assert_eq!(machine_config_diff(&round_trip, &restored), "source"); + } + + #[test] + fn local_pack_base_refuses_linked_inputs_before_preparation() { + let directory = tempfile::tempdir().expect("base inputs"); + let manifest = directory.path().join("image-set.json"); + let kernel = directory.path().join("vmlinux"); + let tar = directory.path().join("base.tar.gz"); + let runtime = directory.path().join("runtime"); + std::fs::create_dir(&runtime).expect("runtime directory"); + for path in [&manifest, &kernel, &tar] { + std::fs::write(path, b"base").expect("base input"); + } + let pr_runtime = Some(LocalPackPrRuntime { + directory: runtime.clone(), + pull_request: 5326, + }); + let base = LocalPackBaseSource::from_selected(&manifest, &kernel, &tar, pr_runtime.clone()) + .expect("regular files selected"); + assert_eq!(base.manifest, std::fs::canonicalize(&manifest).unwrap()); + assert_eq!( + base.pr_runtime.expect("runtime").directory, + std::fs::canonicalize(runtime).unwrap() + ); + #[cfg(unix)] + { + let link = directory.path().join("linked-tar"); + std::os::unix::fs::symlink(&tar, &link).expect("linked base"); + assert!( + LocalPackBaseSource::from_selected(&manifest, &kernel, &link, pr_runtime).is_err(), + "a link cannot substitute a selected base input" + ); + } + } + + #[test] + fn local_pack_source_refuses_changed_or_linked_recipe_before_start() { + let directory = tempfile::tempdir().expect("recipe directory"); + let recipe = directory.path().join("pack.toml"); + std::fs::write(&recipe, b"original").expect("write recipe"); + let source = LocalPackSource::from_prepared( + &recipe, + mvm_core::packs::Sha256Hex::from_bytes(b"verified archive"), + ) + .expect("record recipe"); + assert_eq!( + source.recipe, + std::fs::canonicalize(&recipe).expect("canonical recipe") + ); + source.verify_recipe().expect("unchanged recipe"); + + std::fs::write(&recipe, b"changed").expect("change recipe"); + assert!( + source.verify_recipe().is_err(), + "changed recipe must refuse" + ); + #[cfg(unix)] + { + std::fs::remove_file(&recipe).expect("remove changed recipe"); + std::os::unix::fs::symlink(directory.path().join("elsewhere"), &recipe) + .expect("link recipe"); + assert!(source.verify_recipe().is_err(), "linked recipe must refuse"); + } } #[test] @@ -883,6 +1137,7 @@ mod tests { resolved_digest: None, runtime_pack: false, registry_pack_image: None, + local_pack: None, net: false, allow_host: vec![], peer: Vec::new(), diff --git a/crates/mvm-runtime/src/mock.rs b/crates/mvm-runtime/src/mock.rs index f952965682..889d9ad8e0 100644 --- a/crates/mvm-runtime/src/mock.rs +++ b/crates/mvm-runtime/src/mock.rs @@ -507,6 +507,8 @@ mod tests { runtime_overlay_roothash: None, runtime_overlay_version: None, runtime_release_proof: None, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof: None, legacy_bundle_runtime: None, legacy_portable_runtime: None, source_runtime_archive_sha256: None, diff --git a/crates/mvm-runtime/src/runtime_evidence.rs b/crates/mvm-runtime/src/runtime_evidence.rs index 7a59d5ab87..aa4c8201eb 100644 --- a/crates/mvm-runtime/src/runtime_evidence.rs +++ b/crates/mvm-runtime/src/runtime_evidence.rs @@ -32,6 +32,8 @@ pub const GUEST_RPC_PROTOCOL_VERSION: u32 = mvm_agentd::vsock::PROTOCOL_VERSION; #[derive(Debug)] pub enum VerifiedLaunchRuntime { Release(VerifiedRuntimeManifest), + #[cfg(feature = "pr-test-guest-runtime")] + PrTest(VerifiedRuntimeManifest), Source(CheckedSourceRuntime), LegacyBundle(crate::legacy_bundle_runtime::VerifiedLegacyBundleRuntime), LegacyPortable(crate::legacy_portable_runtime::VerifiedLegacyPortableRuntime), @@ -45,6 +47,8 @@ impl VerifiedLaunchRuntime { pub fn declaration(&self, arch: GuestArch) -> Result<&RuntimeAssembly> { match self { Self::Release(runtime) => Ok(runtime.declaration(arch)?), + #[cfg(feature = "pr-test-guest-runtime")] + Self::PrTest(runtime) => Ok(runtime.declaration(arch)?), Self::Source(runtime) => Ok(runtime.declaration(arch)?), Self::LegacyBundle(_) | Self::LegacyPortable(_) => { bail!("a legacy runtime has no CLI runtime assembly") @@ -59,6 +63,8 @@ impl VerifiedLaunchRuntime { ) -> Result { match self { Self::Release(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), + #[cfg(feature = "pr-test-guest-runtime")] + Self::PrTest(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), Self::Source(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), Self::LegacyBundle(_) | Self::LegacyPortable(_) => { bail!("a legacy runtime has no CLI runtime assembly") @@ -118,6 +124,26 @@ pub fn verify_runtime_for_claim( if checked.is_release() != parent_assembly.release_proof().is_some() { bail!("warm child cannot change its parent's runtime provenance tier"); } + #[cfg(feature = "pr-test-guest-runtime")] + if matches!(checked, VerifiedLaunchRuntime::PrTest(_)) + != matches!( + parent_assembly.provenance, + RuntimeBindingProvenance::PrTest { .. } + ) + { + bail!("warm child cannot change PR test runtime provenance"); + } + #[cfg(feature = "pr-test-guest-runtime")] + if let (Some(requested), RuntimeBindingProvenance::PrTest { proof: parent }) = ( + config.pr_test_runtime_proof.as_ref(), + &parent_assembly.provenance, + ) { + anyhow::ensure!( + requested.pull_request == parent.pull_request + && requested.input.archive_sha256 == parent.input.archive_sha256, + "warm child requested a different PR test runtime" + ); + } if let ( VerifiedLaunchRuntime::Source(checked), RuntimeBindingProvenance::Source { archive_sha256, .. }, @@ -170,6 +196,14 @@ pub fn verify_runtime_binding(binding: &RuntimeLaunchBinding) -> Result<()> { RuntimeBindingProvenance::Release { proof } => VerifiedLaunchRuntime::Release( verify_runtime_release_proof(proof, binding.version(), GuestArch::host())?, ), + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => VerifiedLaunchRuntime::PrTest( + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + binding.version(), + GuestArch::host(), + )?, + ), RuntimeBindingProvenance::Source { version, arch, @@ -474,7 +508,13 @@ fn validate_generation_paths(binding: &CliRuntimeLaunchBinding) -> Result Some(&proof.input), + RuntimeBindingProvenance::Release { .. } | RuntimeBindingProvenance::Source { .. } => None, + }); + if let Some(proof) = proof { for (path, name) in [ (&proof.archive, "archive"), (&proof.checksum, "checksum"), @@ -511,6 +551,11 @@ pub fn verify_release_runtime_for_launch( minimum_protocol: u32, ) -> Result { check_runtime_provenance(config)?; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + config.pr_test_runtime_proof.is_none(), + "PR test runtime cannot satisfy release runtime assurance" + ); if config.unsigned_runtime_development || config.source_runtime_archive_sha256.is_some() || config.legacy_bundle_runtime.is_some() @@ -569,12 +614,32 @@ fn verify_runtime_for_launch_at( version, GuestArch::host(), )?), - None => VerifiedLaunchRuntime::Source(checked_source( - version, - GuestArch::host(), - bound_home, - config.source_runtime_archive_sha256.as_deref(), - )?), + None => { + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &config.pr_test_runtime_proof { + VerifiedLaunchRuntime::PrTest( + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + version, + GuestArch::host(), + )?, + ) + } else { + VerifiedLaunchRuntime::Source(checked_source( + version, + GuestArch::host(), + bound_home, + config.source_runtime_archive_sha256.as_deref(), + )?) + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + VerifiedLaunchRuntime::Source(checked_source( + version, + GuestArch::host(), + bound_home, + config.source_runtime_archive_sha256.as_deref(), + )?) + } }; let declaration = verified.declaration(GuestArch::host())?; if declaration.protocol_version < minimum_protocol @@ -593,6 +658,8 @@ fn check_runtime_provenance(config: &VmStartConfig) -> Result<()> { config.runtime_release_proof.is_some(), config.legacy_bundle_runtime.is_some(), config.legacy_portable_runtime.is_some(), + #[cfg(feature = "pr-test-guest-runtime")] + config.pr_test_runtime_proof.is_some(), ] .into_iter() .filter(|present| *present) @@ -609,6 +676,16 @@ fn check_runtime_provenance(config: &VmStartConfig) -> Result<()> { fn has_external_runtime(config: &VmStartConfig) -> bool { config.runtime_release_proof.is_some() + || { + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof.is_some() + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + false + } + } || config.source_runtime_archive_sha256.is_some() || config.runtime_overlay_path.is_some() || config.runtime_overlay_verity_path.is_some() @@ -797,9 +874,24 @@ impl RuntimeLifecycleLease { )?, ); } - bound.unsigned_runtime_development = !verified.is_release(); + #[cfg(feature = "pr-test-guest-runtime")] + if let VerifiedLaunchRuntime::PrTest(pr_test) = &verified { + let proof = config + .pr_test_runtime_proof + .as_ref() + .context("PR test proof missing")?; + bound.pr_test_runtime_proof = + Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: pr_test + .retain_release_proof_in(&proof.input, artifacts.directory_path())?, + pull_request: proof.pull_request, + }); + } + bound.unsigned_runtime_development = matches!(verified, VerifiedLaunchRuntime::Source(_)); bound.source_runtime_archive_sha256 = match &verified { VerifiedLaunchRuntime::Source(source) => Some(source.archive_sha256().to_owned()), + #[cfg(feature = "pr-test-guest-runtime")] + VerifiedLaunchRuntime::PrTest(_) => None, VerifiedLaunchRuntime::Release(_) | VerifiedLaunchRuntime::LegacyBundle(_) | VerifiedLaunchRuntime::LegacyPortable(_) => None, @@ -809,9 +901,18 @@ impl RuntimeLifecycleLease { Some(artifacts.overlay_verity_path().display().to_string()); bound.initrd_path = Some(artifacts.initramfs_path().display().to_string()); let binding = artifacts.directory_path().join("launch.json"); + let provenance = match &verified { + VerifiedLaunchRuntime::Release(_) => "release", + #[cfg(feature = "pr-test-guest-runtime")] + VerifiedLaunchRuntime::PrTest(_) => "pr_test", + VerifiedLaunchRuntime::Source(_) => "source", + VerifiedLaunchRuntime::LegacyBundle(_) | VerifiedLaunchRuntime::LegacyPortable(_) => { + unreachable!("legacy runtimes returned before CLI assembly") + } + }; let record = serde_json::json!({ "vm_name": config.name, - "provenance": if verified.is_release() { "release" } else { "source" }, + "provenance": provenance, "archive_sha256": artifacts.archive_sha256(), "overlay_roothash": artifacts.declaration().overlay_roothash, "overlay": bound.runtime_overlay_path, diff --git a/crates/mvm-runtime/src/trusted_snapshot_service.rs b/crates/mvm-runtime/src/trusted_snapshot_service.rs index ddeb344dc5..6cb725df38 100644 --- a/crates/mvm-runtime/src/trusted_snapshot_service.rs +++ b/crates/mvm-runtime/src/trusted_snapshot_service.rs @@ -8,29 +8,42 @@ use std::collections::BTreeMap; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use anyhow::{Context, Result, anyhow, bail}; use mvm_fs::clone::CloneStrategy; use mvm_fs::snapshot_store::SnapshotId; use mvm_fs::trusted_snapshot::TrustedSnapshotBackend; +use uuid::Uuid; /// Opaque service-issued reference to one sealed snapshot publication. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrustedSnapshotHandle { id: SnapshotId, + generation: Uuid, } impl TrustedSnapshotHandle { fn new(id: SnapshotId) -> Self { - Self { id } + Self { + id, + generation: Uuid::new_v4(), + } } } +enum PublicationState { + Reserved, + Published(TrustedSnapshotHandle), + Removing(TrustedSnapshotHandle), +} + /// Resident owner of trusted snapshot publications. pub struct TrustedSnapshotService { backend: Arc, - publications: Mutex>, + // Reserved also quarantines an ID after failed staging or cleanup. + // The per-ID lock holds claims open until materialization finishes. + publications: Mutex>>>, } impl TrustedSnapshotService { @@ -44,37 +57,43 @@ impl TrustedSnapshotService { /// Stage and seal a publication, then return its opaque claim handle. /// - /// The handle is inserted only after sealing succeeds. A failed stage or - /// seal therefore cannot become claimable through this service. + /// The ID is reserved before staging; only sealed publications are claimable. pub fn publish(&self, id: SnapshotId, source: &Path) -> Result { let key = id.id().to_owned(); - { - let publications = self - .publications - .lock() - .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; - if publications.contains_key(&key) { - bail!("trusted snapshot '{key}' is already published"); - } + let entry = Arc::new(RwLock::new(PublicationState::Reserved)); + let mut publications = self + .publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; + if publications.contains_key(&key) { + bail!("trusted snapshot '{key}' is already published or reserved"); } + publications.insert(key.clone(), Arc::clone(&entry)); + drop(publications); + // A failed stage may have left partial backend state, or encountered + // state belonging to another owner. Without an ownership proof, do + // not remove that state or release this ID for reuse. self.backend .stage(&id, source) .with_context(|| format!("stage trusted snapshot {key}"))?; if let Err(error) = self.backend.seal(&id) { - let _ = self.backend.remove(&id); + // A successful stage belongs to this reservation; cleanup before + // releasing the ID so a later publisher cannot race old bytes. + if self.backend.remove(&id).is_ok() { + self.publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? + .remove(&key); + } return Err(error).with_context(|| format!("seal trusted snapshot {key}")); } let handle = TrustedSnapshotHandle::new(id); - let mut publications = self - .publications - .lock() - .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; - if publications.insert(key.clone(), handle.clone()).is_some() { - let _ = self.backend.remove(&handle.id); - bail!("trusted snapshot '{key}' was published concurrently"); - } + *entry + .write() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))? = + PublicationState::Published(handle.clone()); Ok(handle) } @@ -87,15 +106,21 @@ impl TrustedSnapshotService { handle: &TrustedSnapshotHandle, destination: &Path, ) -> Result { - let known = self + let entry = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? .get(handle.id.id()) - .is_some_and(|published| published == handle); - if !known { + .cloned() + .ok_or_else(|| anyhow!("trusted snapshot handle is not owned by this service"))?; + let published = entry + .read() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + if !matches!(&*published, PublicationState::Published(current) if current == handle) { bail!("trusted snapshot handle is not owned by this service"); } + // Keep the read lease through the backend call so removal cannot + // reclaim the sealed read view while a claim is in progress. self.backend .materialize(&handle.id, destination) .with_context(|| format!("materialize trusted snapshot {}", handle.id)) @@ -103,26 +128,49 @@ impl TrustedSnapshotService { /// Remove a publication after all claims have released it. pub fn remove(&self, handle: TrustedSnapshotHandle) -> Result<()> { - let removed = self + let entry = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? - .remove(handle.id.id()); - if removed.as_ref() != Some(&handle) { + .get(handle.id.id()) + .cloned() + .ok_or_else(|| anyhow!("trusted snapshot handle is not owned by this service"))?; + let mut state = entry + .write() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + if !matches!( + &*state, + PublicationState::Published(current) | PublicationState::Removing(current) + if current == &handle + ) { bail!("trusted snapshot handle is not owned by this service"); } + *state = PublicationState::Removing(handle.clone()); self.backend .remove(&handle.id) - .with_context(|| format!("remove trusted snapshot {}", handle.id)) + .with_context(|| format!("remove trusted snapshot {}", handle.id))?; + self.publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? + .remove(handle.id.id()); + Ok(()) } /// Number of sealed publications currently owned by the service. pub fn publication_count(&self) -> Result { - Ok(self + let entries: Vec<_> = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? - .len()) + .values() + .cloned() + .collect(); + entries.into_iter().try_fold(0, |count, entry| { + let state = entry + .read() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + Ok(count + usize::from(matches!(&*state, PublicationState::Published(_)))) + }) } } @@ -131,9 +179,13 @@ mod tests { use super::*; use mvm_fs::snapshot_store::{FsSnapshotStore, SnapshotStore}; use std::io; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::mpsc; struct TestBackend { store: FsSnapshotStore, + fail_next_seal: AtomicBool, + fail_next_remove: AtomicBool, } impl TrustedSnapshotBackend for TestBackend { @@ -142,6 +194,9 @@ mod tests { } fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + if self.fail_next_seal.swap(false, Ordering::SeqCst) { + return Err(io::Error::other("injected sealing failure")); + } Ok(()) } @@ -166,6 +221,9 @@ mod tests { } fn remove(&self, id: &SnapshotId) -> io::Result<()> { + if self.fail_next_remove.swap(false, Ordering::SeqCst) { + return Err(io::Error::other("injected removal failure")); + } self.store.remove(id) } @@ -182,6 +240,8 @@ mod tests { std::fs::write(source.join("rootfs.ext4"), b"immutable-test-bytes").unwrap(); let service = TrustedSnapshotService::new(Arc::new(TestBackend { store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), })); let handle = service .publish(SnapshotId::new("snapshot-1"), &source) @@ -198,11 +258,281 @@ mod tests { assert_eq!(service.publication_count().unwrap(), 0); } + #[test] + fn a_failed_seal_releases_its_reservation_only_after_cleanup() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(true), + fail_next_remove: AtomicBool::new(false), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("seal-retry"); + assert!(service.publish(id.clone(), &source).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + let handle = service.publish(id, &source).unwrap(); + service.remove(handle).unwrap(); + } + + #[test] + fn failed_seal_cleanup_quarantines_the_id() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(true), + fail_next_remove: AtomicBool::new(true), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("quarantined"); + assert!(service.publish(id.clone(), &source).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + assert!(service.publish(id, &source).is_err()); + } + + #[test] + fn failed_removal_refuses_claims_and_can_be_retried_by_the_owner() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(true), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("retry-removal"); + let handle = service.publish(id.clone(), &source).unwrap(); + assert!(service.remove(handle.clone()).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + assert!( + service + .materialize(&handle, &tmp.path().join("claim")) + .is_err() + ); + assert!(service.publish(id, &source).is_err()); + service.remove(handle).unwrap(); + assert_eq!(service.publication_count().unwrap(), 0); + } + + #[test] + fn a_removed_handle_cannot_claim_a_new_publication_under_the_same_id() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("rootfs.ext4"), b"first").unwrap(); + let service = TrustedSnapshotService::new(Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), + })); + let id = SnapshotId::new("reused-id"); + let stale = service.publish(id.clone(), &source).unwrap(); + service.remove(stale.clone()).unwrap(); + std::fs::write(source.join("rootfs.ext4"), b"second").unwrap(); + let current = service.publish(id, &source).unwrap(); + let destination = tmp.path().join("destination"); + + assert_ne!( + stale, current, + "a new publication must have a new generation" + ); + assert!(service.materialize(&stale, &destination).is_err()); + assert!(!destination.exists(), "stale handle reached the backend"); + assert!(service.remove(stale).is_err()); + assert_eq!(service.publication_count().unwrap(), 1); + service.materialize(¤t, &destination).unwrap(); + assert_eq!( + std::fs::read(destination.join("rootfs.ext4")).unwrap(), + b"second" + ); + } + + struct PausedPublisher { + entered: mpsc::Sender<()>, + resume: Mutex>, + staged: AtomicUsize, + removed: AtomicUsize, + } + + impl TrustedSnapshotBackend for PausedPublisher { + fn stage(&self, _id: &SnapshotId, _source: &Path) -> io::Result<()> { + if self.staged.fetch_add(1, Ordering::SeqCst) == 0 { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + Ok(()) + } + + fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + Ok(()) + } + + fn validate( + &self, + _id: &SnapshotId, + _manifest_digest: &str, + _trusted_signer: &[u8; 32], + ) -> io::Result<()> { + Ok(()) + } + + fn materialize(&self, _id: &SnapshotId, _dst: &Path) -> io::Result { + Ok(CloneStrategy::Copied) + } + + fn remove(&self, _id: &SnapshotId) -> io::Result<()> { + self.removed.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn name(&self) -> &'static str { + "paused-test" + } + } + + #[test] + fn a_concurrent_publisher_cannot_stage_or_remove_an_owners_publication() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let backend = Arc::new(PausedPublisher { + entered: entered_tx, + resume: Mutex::new(resume_rx), + staged: AtomicUsize::new(0), + removed: AtomicUsize::new(0), + }); + let service = Arc::new(TrustedSnapshotService::new(backend.clone())); + let first = { + let service = service.clone(); + std::thread::spawn(move || service.publish(SnapshotId::new("same-id"), Path::new("."))) + }; + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("first publisher reached the backend"); + let (second_tx, second_rx) = mpsc::channel(); + let second = { + let service = service.clone(); + std::thread::spawn(move || { + second_tx + .send(service.publish(SnapshotId::new("same-id"), Path::new("."))) + .unwrap(); + }) + }; + let second_result = second_rx.recv_timeout(std::time::Duration::from_secs(2)); + resume_tx.send(()).unwrap(); + let first_handle = first.join().unwrap().expect("first publisher owns the ID"); + second.join().unwrap(); + assert!( + second_result + .expect("duplicate must refuse while first is staging") + .is_err(), + "a concurrent caller published the same ID" + ); + assert_eq!(service.publication_count().unwrap(), 1); + assert_eq!(backend.staged.load(Ordering::SeqCst), 1); + assert_eq!(backend.removed.load(Ordering::SeqCst), 0); + service.materialize(&first_handle, Path::new(".")).unwrap(); + } + + struct PausedClaim { + entered: mpsc::Sender<()>, + resume: Mutex>, + removed: AtomicUsize, + } + + impl TrustedSnapshotBackend for PausedClaim { + fn stage(&self, _id: &SnapshotId, _source: &Path) -> io::Result<()> { + Ok(()) + } + + fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + Ok(()) + } + + fn validate( + &self, + _id: &SnapshotId, + _manifest_digest: &str, + _trusted_signer: &[u8; 32], + ) -> io::Result<()> { + Ok(()) + } + + fn materialize(&self, _id: &SnapshotId, _dst: &Path) -> io::Result { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert_eq!(self.removed.load(Ordering::SeqCst), 0); + Ok(CloneStrategy::Copied) + } + + fn remove(&self, _id: &SnapshotId) -> io::Result<()> { + self.removed.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn name(&self) -> &'static str { + "paused-claim-test" + } + } + + #[test] + fn removal_waits_for_an_in_flight_claim() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let backend = Arc::new(PausedClaim { + entered: entered_tx, + resume: Mutex::new(resume_rx), + removed: AtomicUsize::new(0), + }); + let service = Arc::new(TrustedSnapshotService::new(backend.clone())); + let handle = service + .publish(SnapshotId::new("leased"), Path::new(".")) + .unwrap(); + let claim = { + let service = service.clone(); + let handle = handle.clone(); + std::thread::spawn(move || service.materialize(&handle, Path::new("."))) + }; + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("the backend entered materialization"); + let (removed_tx, removed_rx) = mpsc::channel(); + let removal = { + let service = service.clone(); + std::thread::spawn(move || removed_tx.send(service.remove(handle)).unwrap()) + }; + assert!( + removed_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "removal completed before the active claim" + ); + resume_tx.send(()).unwrap(); + claim.join().unwrap().unwrap(); + removal.join().unwrap(); + removed_rx.recv().unwrap().unwrap(); + assert_eq!(backend.removed.load(Ordering::SeqCst), 1); + assert_eq!(service.publication_count().unwrap(), 0); + } + #[test] fn service_rejects_unknown_handle_without_materializing() { let tmp = tempfile::tempdir().unwrap(); let service = TrustedSnapshotService::new(Arc::new(TestBackend { store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), })); let unknown = TrustedSnapshotHandle::new(SnapshotId::new("not-published")); let destination = tmp.path().join("destination"); diff --git a/crates/mvm-runtime/src/universal_initramfs.rs b/crates/mvm-runtime/src/universal_initramfs.rs index a5e538f414..88ecc5fb6b 100644 --- a/crates/mvm-runtime/src/universal_initramfs.rs +++ b/crates/mvm-runtime/src/universal_initramfs.rs @@ -114,6 +114,14 @@ pub fn attach_universal_initramfs_if_cached( start_config: &mut mvm_core::vm_backend::VmStartConfig, hypervisor: &str, ) -> Result<()> { + #[cfg(feature = "pr-test-guest-runtime")] + if start_config.pr_test_runtime_proof.is_some() { + crate::runtime_evidence::verify_runtime_for_launch( + start_config, + crate::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + )?; + return Ok(()); + } if start_config.legacy_portable_runtime.is_some() { crate::runtime_evidence::verify_runtime_for_launch( start_config, diff --git a/crates/mvm-runtime/src/workload_runner/standby_boot.rs b/crates/mvm-runtime/src/workload_runner/standby_boot.rs index aa35166584..9588d28783 100644 --- a/crates/mvm-runtime/src/workload_runner/standby_boot.rs +++ b/crates/mvm-runtime/src/workload_runner/standby_boot.rs @@ -179,12 +179,20 @@ pub fn factory_parent_config( runtime_overlay_roothash, runtime_overlay_version, runtime_release_proof, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof, unsigned_runtime_development, source_runtime_archive_sha256, mem_initial_mib, kernel_read_sha256, } = launch; + #[cfg(feature = "pr-test-guest-runtime")] + if pr_test_runtime_proof.is_some() { + return Err(StandbyError::SpawnFailed( + "PR test runtime supports cold launches only".into(), + )); + } if canonical_kernel.is_some() || kernel_read_sha256.is_some() { return Err(StandbyError::SpawnFailed( "kernel descriptor custody is cold-only; standby reuse is unsupported".into(), @@ -225,6 +233,8 @@ pub fn factory_parent_config( runtime_overlay_roothash: runtime_overlay_roothash.clone(), runtime_overlay_version: runtime_overlay_version.clone(), runtime_release_proof: runtime_release_proof.clone(), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof: None, unsigned_runtime_development: *unsigned_runtime_development, source_runtime_archive_sha256: source_runtime_archive_sha256.clone(), mem_initial_mib: *mem_initial_mib, diff --git a/crates/mvm-sdk/src/pack/composable.rs b/crates/mvm-sdk/src/pack/composable.rs index f949ca4b80..3138b79b6b 100644 --- a/crates/mvm-sdk/src/pack/composable.rs +++ b/crates/mvm-sdk/src/pack/composable.rs @@ -146,6 +146,23 @@ cpu_cores = 1 memory_mb = 256 "#; + #[test] + fn synthetic_cli_guest_value_is_a_valid_authored_input() { + let recipe = include_str!("../../../../tests/fixtures/composable-cli-value/pack.toml"); + let spec = parse_toml(recipe).unwrap(); + assert!(spec.runtimes.is_empty()); + assert_eq!(spec.entrypoint, ["/bin/cat", "/srv/app/value.txt"]); + #[cfg(feature = "compiler")] + { + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/composable-cli-value"); + prepare_composable_application(&spec, &directory) + .unwrap() + .reverify() + .unwrap(); + } + } + #[test] fn dev_is_the_default_and_prod_requires_a_separate_table() { let spec = parse_toml(PYTHON).unwrap(); diff --git a/crates/mvm-vmm/Cargo.toml b/crates/mvm-vmm/Cargo.toml index 9f5c406cd7..e63060361f 100644 --- a/crates/mvm-vmm/Cargo.toml +++ b/crates/mvm-vmm/Cargo.toml @@ -71,6 +71,9 @@ workspace = true [features] default = [] +# PR-only runtime evidence changes the universal-initramfs activation gate, +# never production authority. +pr-test-guest-runtime = ["mvm-core/pr-test-guest-runtime"] # The test-support surface builds plan fixtures out of mvm-core, so it has to # turn that crate's feature on itself. Leaving it off only worked while every # consumer happened to enable mvm-core/test-support for its own reasons. diff --git a/crates/mvm-vmm/src/host/boot_config.rs b/crates/mvm-vmm/src/host/boot_config.rs index 1b98420dd1..ce37bb6ef2 100644 --- a/crates/mvm-vmm/src/host/boot_config.rs +++ b/crates/mvm-vmm/src/host/boot_config.rs @@ -58,9 +58,14 @@ pub fn booted_with_universal_initramfs(config: &mvm_core::vm_backend::VmStartCon let Some(initrd) = &config.initrd_path else { return false; }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test_runtime = config.pr_test_runtime_proof.is_some(); + #[cfg(not(feature = "pr-test-guest-runtime"))] + let pr_test_runtime = false; if config.bundle_boot_assets.is_some() || config.legacy_portable_runtime.is_some() || config.runtime_release_proof.is_some() + || pr_test_runtime || config.unsigned_runtime_development { return true; @@ -214,6 +219,33 @@ mod tests { assert!(!booted_with_universal_initramfs(&without_initramfs)); } + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn a_pr_test_generation_outside_the_cache_must_activate_before_readiness() { + let proof = mvm_core::protocol::runtime_release::RuntimeReleaseProof { + archive: "/pr/archive".into(), + checksum: "/pr/checksum".into(), + signature_bundle: "/pr/signature".into(), + archive_sha256: "a".repeat(64), + version: "0.28.0-rc.1".into(), + arch: mvm_core::arch::GuestArch::host(), + }; + let mut config = mvm_core::vm_backend::VmStartConfig { + initrd_path: Some("/private/pr-runtime/initramfs.cpio.gz".into()), + pr_test_runtime_proof: Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: proof, + pull_request: 5326, + }), + ..Default::default() + }; + assert!(booted_with_universal_initramfs(&config)); + config.initrd_path = None; + assert!(!booted_with_universal_initramfs(&config)); + config.initrd_path = Some("/private/pr-runtime/initramfs.cpio.gz".into()); + config.pr_test_runtime_proof = None; + assert!(!booted_with_universal_initramfs(&config)); + } + #[test] fn a_pinned_bundle_initrd_uses_universal_activation_outside_the_cli_cache() { let mut config = mvm_core::vm_backend::VmStartConfig { diff --git a/public/src/content/docs/guides/pack-authoring.md b/public/src/content/docs/guides/pack-authoring.md index a4fe07fdd5..17d4cafc02 100644 --- a/public/src/content/docs/guides/pack-authoring.md +++ b/public/src/content/docs/guides/pack-authoring.md @@ -10,8 +10,10 @@ and `mvmctl` verifies its signature every time it is pulled and every time a policy that names it is loaded. The registry source is the [`mvm-packs`](https://github.com/tinylabscom/mvm-packs) repository. -This guide uses two published packs as its worked examples: `runtime/node`, a -group, and `agent/codex`, a profile that includes it. For the policy language +The `runtime/node@1.0.0` group and `agent/codex@1.0.0` profile below are +**historical policy-only examples**, not portable application bytes or +independently runnable packs. The newer `agent/claude@2.0.1` is a complete +boot bundle, not an application-only component. For the policy language itself, see [Policy and profiles](/guides/policy-and-profiles/). ## What a pack contains @@ -118,6 +120,76 @@ boot-source precedence; the pack still contributes its policy. The separate `machine run --entrypoint` boot path refuses an image-bearing pack; use the ordinary machine run path. +## Portable application candidate (not yet released) + +The new component model keeps the signed application archive separate from +the selected Linux base and the separately signed CLI guest runtime. A +versioned application-only descriptor binds the payload digest, architecture, +ABI/libc requirements, policy and named commands; it contains no kernel or +bootable rootfs. Runtime/tool components bind their own archive digests and +policy. Existing published versions keep their original meanings. + +The candidate `mvm.pack-spec/v2` developer recipe currently describes one +**local** application and ordered runtime requests: + +```toml +schema = "mvm.pack-spec/v2" +target = "x86_64-linux" +runtimes = ["runtime/python"] +entrypoint = ["/usr/bin/python3", "/srv/app/main.py"] + +[identity] +name = "agent/example" +version = "1.0.0" + +[source] +kind = "local" +path = "." + +[[copy]] +source = "main.py" +destination = "/srv/app/main.py" + +[resources] +cpu_cores = 1 +memory_mb = 256 +``` + +The proposed development invocation is one command with either the default +base or a selected local kernel-and-composition source: + +```sh +mvmctl run --compose ./pack.toml --compose-base signed-default +mvmctl run --compose ./pack.toml --compose-base local \ + --compose-base-manifest /abs/local-image-set.json \ + --compose-base-kernel /abs/vmlinux \ + --compose-base-tar /abs/rootfs.tar.gz +``` + +The local manifest must describe the selected architecture's compatible +kernel and tar with exact digest and size. A base made locally—even from a +sealed Nix output—is development-only. The unsealed `default-tenant.dev` +image has no composition tar; an ext4-only local manifest is insufficient. +Neither command is yet a demonstrated `mvmctl` guest-output/cache-hit +workflow: the latest signed image set lacks a default composition tar and +there is no matching released full CLI runtime. Unknown/mismatched inputs +must refuse instead of falling back to another base. + +Local assembly measures ext4/verity before boot and signs a **local** +bundle/plan. The source candidate privately stages each invocation rather +than installing its composed bundle; a repeat rechecks cached bytes and +needs a fresh Dev admission. This does not create publisher authority. +If an admitted run fails, its private evidence may be retained at the path +printed on stderr for recovery; stop the VM before removing that directory. +Production export +requires separately trusted signed base and runtime evidence and +independent protected verification/signing of one self-contained +`.mvmpkg`. Neither `--prod` on a local composition nor +`mvmctl pack publish ./my-pack` is implemented as that protected path. +Only a proved CLI cold/repeat guest value plus admission/cache-hit check +can establish the candidate workflow. No guest NIC or network fallback +is part of this model; permitted egress remains FlowMux/vsock. + ## A group pack A group is a reusable fragment: hosts, secret bindings, shares, environment diff --git a/public/src/content/docs/reference/cli-commands.md b/public/src/content/docs/reference/cli-commands.md index 89409f587f..5ce2db5b2a 100644 --- a/public/src/content/docs/reference/cli-commands.md +++ b/public/src/content/docs/reference/cli-commands.md @@ -985,9 +985,10 @@ never served from a standby and always boots cold. | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mvmctl run -- ...` | Boot the bundled default microVM image, run ``, exit | | `mvmctl run -- ...` | Boot an OCI image; the same as `--image ` | -| `mvmctl run ./app.mvmpkg -- ...` | Verify and boot a publisher-trusted bundle or a same-host local build; local builds are reported as not release-verified and refused under `--prod` | +| `mvmctl run ./app.mvmpkg -- ...` | Verify and boot a publisher-trusted bundle or an explicitly enrolled same-host local build; schema-8+ local builds require operator enrollment and remain refused under `--prod` | | `mvmctl run -- ...` | Acquire and run an `agent/*` application, or run another installed application; `-a`, `--app` and `--application` acquire any application reference with no image fallback | -| `mvmctl run --compose ./pack.toml` | Resolve signed runtime inputs, assemble a verity-sealed default base and run the recipe's entrypoint from a host-signed **local build**. Rechecks publisher and revocation evidence on cache hits; refuses until a compatible signed Linux image set supplies a composition tar. Never publishes a pack or claims an official signature. | +| `mvmctl run --compose ./pack.toml` | Resolve signed runtime inputs and privately stage a host-signed **Dev** composition for this invocation, without installing it as a reusable bundle. Rechecks publisher and revocation evidence on cache hits; refuses until a compatible signed Linux image set supplies a composition tar. Never publishes a pack or claims an official signature. A PR-only x86_64 Firecracker test produced guest output on both a cold run and an authenticated cache hit; this is not a published CLI or a speed witness. | +| `mvmctl run --compose ./pack.toml --compose-base local --compose-base-manifest ./base/image-set.json --compose-base-kernel ./base/vmlinux --compose-base-tar ./base/rootfs.tar.gz` | Select an explicitly measured local kernel and composition tar under a schema-3 `local-dev` manifest. Development-only and host-signed; refuses `--prod`, missing tar, changed input bytes and incompatible architecture/boot contract instead of using the signed default. This selected-base path produced guest output on cold and repeat PR-only x86_64 Firecracker runs, not a release or a speed claim. | | `mvmctl run --manifest -- ...` | Boot a registered manifest/template instead of the default | | `mvmctl run --manifest -- ...` | Verify, install, and boot a signed `.mvmpkg` bundle, as `machine run --manifest ` does | | `mvmctl run -- npm test` | Infer the runtime when no source is given (see **Runtime detection** below) | @@ -1520,6 +1521,17 @@ both. When `--image` is omitted, it searches the current directory for persisted spec carries the manifest's image, CPU/memory sizing, `mem_initial`, network defaults, allow-hosts, volumes, and its `[grants]` table. +The draft development-only `machine create --pack ` instead +records a local recipe and its verified archive digest as data, without +enrolling a publisher. Hidden `--pack-base-manifest`, `--pack-base-kernel` and +`--pack-base-tar` select a measured local base; PR-test-enabled builds also +accept a signed test runtime with `--pack-pr-test-runtime-dir` and +`--pack-pr-test-pull-request`. `machine start ` must recheck those inputs +and freshly admit the exact bundle. PR-only x86_64 Firecracker and native +macOS arm64 HVF tests observed guest output on named cold starts and +stop/restart cache hits. None met the launch-speed targets; this path does not +grant production, installation, or publisher authority. + A workload's grants — what it may consume and where it may reach — can be declared in four places, resolved **per dimension** from highest precedence to lowest: CLI flags, a `--grants-file` JSON document, the manifest's `[grants]` diff --git a/specs/adrs/059-immutable-warm-boot-read-leases.md b/specs/adrs/059-immutable-warm-boot-read-leases.md new file mode 100644 index 0000000000..8305ff8db7 --- /dev/null +++ b/specs/adrs/059-immutable-warm-boot-read-leases.md @@ -0,0 +1,94 @@ +# ADR-059: Immutable read leases for warm boot inputs + +## Status + +Accepted direction. The owner approved one typed publication/claim API with +bounded sealed memfds, inode-pinned fs-verity where supported, and fail-closed +warm claims. No backend or launch path implements this ADR yet; enabling warm +claims still requires an owner/descriptor API review and live witnesses. + +## Context + +ADR-001 trusts the host, not a compromised guest or another local process. +ADR-025 permits fresh children of a clean, authority-free parent but refuses +reuse of a dirty guest. The composable-pack contract requires fresh admission +and <300 ms cold / <50 ms warm launch including admission. Today +`TrustedSnapshotBackend::materialize` returns a writable destination, the +named path disables its warm pool, and PR runtime and kernel descriptor +custody refuse standby. None is an immutable VMM read lease. + +On a Raspberry Pi 4 Linux/KVM host, a test-only direct Firecracker probe +sealed an 8.5 MB kernel and 71.3 MB ext4 rootfs in memfds. A write after +`F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL` failed; inherited +`/proc/self/fd/N` paths were accepted by pinned Firecracker, and the guest +mounted the root ext4 read-only and ran `/bin/sh` as init. The probe had no +admission, scoped launcher, runtime overlay or parent claim. The Pi kernel +does not advertise fs-verity. The ordinary named start on that host still +took 53 s, including fresh admission; the probe proves no speed target. + +## Decision + +Extend the existing `TrustedSnapshotBackend` and `TrustedSnapshotService`. +The owner accepts an authenticated, exact boot closure (selected kernel, +rootfs, full runtime, parent device shape and publisher/revocation evidence), +reserves its memory and descriptor budget, then publishes and seals its bytes. +A successful publication issues a generation-bound, nonserializable handle; +a claim yields an **owned read lease**, never an arbitrary source path or a +writable materialization. A handle or cache hit grants no plan authority. + +On Linux, sealed memfds serve small boot inputs and explicitly budgeted +resident parents. A separately validated fs-verity provider may pin open +inodes for large rootfs files; its measured verity identity must be bound +to the authenticated byte digest at publication. No permission bit, reflink +or sidecar signature stands in for kernel-backed immutability. A requested +warm guarantee refuses before execution when the selected provider lacks +support, sealing fails, or the byte/FD/parent budget is exhausted. A normal +cold start can proceed through its own full verification and admission, but +must not be reported as a warm claim. The provider is fixed at publication; +claim does not silently switch mechanisms. + +The lease owns its descriptors and publication generation through the actual +VMM's last child, not only until a copy or launch call returns. Reuse the +Firecracker launcher descriptor handoff and verify the VMM's opened objects, +not the diagnostic pathname. The scoped launcher, device layout, guest +no-NIC/vsock-only network path and host budget remain mandatory. APFS must +provide an independently witnessed read-only mount lease before enabling +this claim on macOS; unsupported platforms refuse it. + +A parent is clean: no tenant plan, secret, writable volume, egress destination +or previous child's guest memory. Each child gets fresh signed-plan, +revocation, policy, grant and one-use admission before any admitted effect or +readiness claim. A changed closure, stale handle, lost owner or expired proof +refuses a claim. Owner restart loses memfds and must rebuild from newly +authenticated inputs; no persisted filename recovers a lease. A VMM that +retains its own original descriptors cannot become a new parent after owner +loss; existing children remain subject to their original admitted bounds +or are stopped when those bounds cannot be enforced. + +The owner moves an ID through reserved, sealed, leased, removing and +quarantined states. Failed staging or cleanup keeps it unclaimable. Removal +waits for every active child and VMM descriptor; cancellation and process +death release reservations without reassigning stale generations. Claim-time +checks inspect sealed objects and current authority, not payload hashes. + +## Alternatives + +- Unlimited memfds are simple and work on the Pi, but one copy per distinct + parent shape can exhaust RAM or swap; no 1,000-workload capacity claim + follows without sharing and an enforced host budget. +- fs-verity-only storage avoids the shmem copy but is unavailable on the Pi + and some hosts. It still needs an inode-pinned descriptor and a signed + identity binding rather than trusting a replaceable path. +- Reflinks, read-only modes and host-signed cache sidecars do not seal the + bytes a VMM opens. They cannot satisfy a warm read lease. + +## Evidence required before enabling + +Exercise seal/mutate refusal, manifest/signer mismatch, stale generations, +concurrent publish/claim/remove, memory and FD exhaustion, cancellation, +owner crash/restart, pathname replacement and actual VMM descriptor identity. +Run normal scoped Firecracker launches on Linux x86_64 and arm64 with fresh +admission, verified guest output and no guest NIC. Witness APFS mount custody +separately. Measure first-use publication apart from cold and warm readiness; +the latter include admission and must meet the stated thresholds on named +hosts. Tests or a direct-VMM probe cannot substitute for those live claims. diff --git a/specs/contracts/composable-pack-build.md b/specs/contracts/composable-pack-build.md index d684794738..18cdcad8db 100644 --- a/specs/contracts/composable-pack-build.md +++ b/specs/contracts/composable-pack-build.md @@ -1,382 +1,311 @@ -# Architecture — composable pack builds - -Decision: compose runtime layers at package-build time into one self-contained -application `.mvmpkg` per architecture. A closed runtime-layer descriptor -parser, publisher-authenticated metadata dispatch and exact-byte private -archive snapshot exist in source. Strict isolated tar staging and bounded, -dependency-first resolution of authenticated runtime metadata also exist, -as does bounded composition of authenticated runtime tar inputs into a private -filesystem tree. A producer-side signed base composition tar and -consumer-side authenticated fetch/base-plus-runtime composition are implemented -in source for future Linux sets. Application input, policy, archive installation, -authoring and final non-Nix `.mvmpkg` production are not integrated; no base -tar has been published or boot-witnessed. This decision does not authorize -publication or change existing signed pack versions. - -## Problem and goals - -An author describes one sandbox once. A development build is the default; an -explicit production build freezes and checks every input before it can produce -a signed, self-contained `.mvmpkg`. A `runtime/*` pack contributes reusable -software and policy, not another VM or another entrypoint. An application pack -contributes the single entrypoint. For example, composing `runtime/python` with -a Python application yields one VM with both programs and one application -entrypoint. - -`mvm-images` owns the generic, signed Linux base. It does not grow per-runtime -roles. `mvm-packs` owns immutable versioned pack descriptors, signed layer -inputs and their policy. `mvm` owns authoring, resolution, composition, the -final workload archive and consumer admission. No package build publishes a -new Linux base. - -The priorities are simple authoring, fast cold and repeat package builds, -small generic Linux bases, and no regression in offline verification or the -guest's isolation. "Small" describes unnecessary base and builder overhead; -a required Python or agent binary still occupies space in the final archive. - -## Approaches considered - -| Composition | Benefit | Cost | -| --- | --- | --- | -| **At package build (chosen)** | One existing-style sealed rootfs and one offline-bootable archive; runtime input resolution and assembly can be cached separately | Runtime bytes recur in distinct final application archives | -| At VM boot | Potential sharing of independently sealed runtime disks | New boot ABI, multi-disk integrity and mount ordering; embedding the disks still costs bytes in a self-contained archive | -| At install from external references | Smaller individual application downloads if runtimes are cached | No single self-contained file; offline use and trust depend on external installed layers | - -Keep the builder implementation in Rust and use the current filesystem, -verity, bundle and admission code. Nix remains a compatible producer, not -the required resolver or default package-build path. The generic Linux base -continues to be produced and signed by `mvm-images` on its own cadence; -choosing Buildroot instead of Nix for that base is an independent experiment. - -## Representations and ownership - -1. The authored, versioned pack spec says what to include and what the workload - requests. A small, strict TOML front end and the language SDKs produce the - same neutral spec; TOML is not a second execution IR. Common fields are - safe for both modes. Development-only configuration is never inherited by - production. Production requires an explicit mode selection and refuses - incomplete inputs. Keep `[production]` as the one spelled-out table name; - avoid an equivalent `[prod]` alias. Existing `mvm.pack-spec/v1` and lock - bytes retain their current meaning, including their current sealed build - behavior; the new default requires a new versioned contract. -2. The existing Workload IR says what runs. It has one final command entrypoint - and the resources and capabilities the workload requests. A runtime layer - cannot introduce an entrypoint or a host grant. -3. A resolved build plan says which exact bytes to assemble. It binds the - target architecture, accepted signed base-image-set root, an ordered list of - version-and-manifest-digest-pinned runtime layers, the application input, - every external source digest, and the compiler/producer versions. A - versioned lock records this resolved plan; an unpinned input cannot enter - production. The signed final artifact records the resolved inputs and - hashes of its output bytes. A changed authoring file, selected layer, - compiler or producer invalidates the relevant lock/cache key. - -The neutral `mvm.pack-spec/v2` contract and Rust SDK TOML front end now parse -this narrow first slice without changing v1: one local application, an ordered -list of `runtime/*` requests, `/srv/app` copies, literal absolute argv and -resource requests. Development is the default; `[production]` is an explicit -opt-in, not a grant or a completed frozen lock. Unknown networking, host grants -and signing fields are rejected. For example: - -```toml -schema = "mvm.pack-spec/v2" -target = "aarch64-linux" -runtimes = ["runtime/python"] -entrypoint = ["/usr/bin/python3", "/srv/app/main.py"] - -[identity] -name = "agent/example" -version = "1.0.0" - -[source] -kind = "local" -path = "." - -[[copy]] -source = "main.py" -destination = "/srv/app/main.py" - -[resources] -cpu_cores = 1 -memory_mb = 256 -``` - -Adding `[production]` at the end expresses intent but does not currently -make `mvmctl pack build` consume v2; that CLI still requires v1 and Nix. -Authenticated runtime requests can now be resolved in exact dependency order -and checked against candidate archives. The shared compiler snapshots only -declared application copies (or the full source when `.` is copied), then -rehashes those inputs without Nix; unrelated files do not invalidate the v2 -snapshot. The Rust SDK can package that held tree as a deterministic gzip tar -and reverify both the staged files and archive bytes. The private composer -now requires each requested runtime's signed archive and policy-file snapshots -together; a raw verified tar without those policy bytes cannot create a -production-authored lock. It accepts one digest-bound application tar only -under `/srv/app` and emits an ext4+verity candidate. An explicitly -production-authored candidate can produce -a versioned input record binding the spec, supplied source/revision, selected -profile, signed base root, runtime pins and application archive. This is not -yet a CLI-frozen build: source and producer-revision custody, health boot and -signed archive remain required. - -Separate the producer from the builder-VM backend. A producer may use Nix, -verified OCI content or pinned native payloads to make candidate filesystem -content, but it cannot sign, grant policy or publish. Reuse the existing -materialization, verity, exact-byte health boot, verifier and bundle signer -after all candidates pass the same output contract. Signing keys and -publication credentials never enter a builder or an authored spec. - -## Layer, archive and trust boundaries - -Prefer a new `runtime_layer` registry kind over widening the bootable -`.mvmpkg` parser to accept kernel-less inputs. A versioned registry-signed -manifest binds a deterministic, bounded external layer tar by canonical -HTTPS or digest-pinned OCI source, SHA-256 and size. Only the small policy -documents live under the registry's `files/` tree; a multi-GiB tar cannot -live in Git. The signed descriptor also binds the target architecture, -immutable pack identity and declared runtime dependencies. The tar carries -filesystem entries only: no kernel, initramfs, entrypoint, network setup, -whiteouts or executable install script. -Reuse the existing hardened OCI-layer unpacker as far as its safe subset fits, -and reject paths, file types or metadata it cannot faithfully represent. -The registry manifest signature covers the tar digest, so a second layer -signer is unnecessary. This new kind requires a producer and consumer -contract; existing policy and application manifest bytes keep their meaning. - -The first descriptor parser accepts outer `schema_version = 3`, kind -`runtime_layer`, an exact `runtime/*@version` reference and description; -`runtime_layer` carries schema 1, guest architecture and an ordered list of -exact manifest-digest-pinned `runtime/*` dependencies and an `archive` -object with `source`, `archive_sha256` and `archive_size`. Its `files` -inventory contains at least one of `pack/group.toml` or -`pack/profile.toml`, each with a digest and positive size. It rejects -unknown fields, extra file paths, duplicate files, self-dependencies, -non-runtime dependencies, and more than 16 direct dependencies. The -external tar is bounded to 2 GiB; producer and consumer must also bound -its unpacked contents. Parsing alone does not authenticate a publisher; -the distinct registry dispatch does, and the snapshot checks the -descriptor-pinned external size and hash. A separate staging step refuses -unsupported tar entry types, duplicate paths, whiteouts, setid bits, xattrs -and over-budget contents, and protects host-owned guest paths such as -`/etc/mvm`, `/mvm`, `/dev` and `/etc/resolv.conf`. It does not merge a layer -with the Linux base or admit it as a runnable image. The `mvm-packs` -publisher owner has aligned its unpublished producer branch to this shape. -One architecture per version is sufficient for the first Python spike; -multi-architecture fan-out requires a separately versioned schema decision. - -The package builder verifies each selected layer's registry publisher, -manifest signature, file digest, revocation and exact lock pin *before* -reading its files as build input. Signed `pack/group.toml` and -`pack/profile.toml` bytes must be snapshotted and verified as well as the -external tar; neither a catalog hash nor an unverified local policy file can -stand in for the publisher's signed manifest. Policy composition must keep -the signed layer origin and deny/ceiling semantics until host admission. -The protected final signer independently -checks the frozen input evidence and the output selected for health boot, -then signs a complete application `.mvmpkg`. The recipient authenticates -that final archive, its registry pin and current admission policy; it does -not need the original layer registry or source tree at boot. The signed -final manifest records the input layer manifests and output digests so a -later runtime-layer revocation can refuse derived application bundles. -An offline production consumer must preserve the existing freshness rules -for revocation evidence. The final publisher's signature is not proof that -an unverified upstream layer was safe or reproducible. - -Keep the registry's keyless publisher identity and the bundle's independently -enrolled Ed25519 key as two separate trust roots. A versioned in-file keyless -signature, signed build-provenance statement and pack/policy trace identities -are proposed by `mvm-packs` ADR 004, not yet shipped by this candidate path. -Do not label a local resolved lock or host-signed archive as those statements. -The final format needs to bind the producer revision, exact runtime manifest -and policy hashes, base root, generated artifact hashes and health-boot subject -under the same independently verified provenance and admission rules; if the -consumer cannot verify them, it must refuse production. - -## Composition and security rules - -- Resolve runtime references to immutable, authenticated versions before - composition; reject duplicates, cycles, architecture mismatches, revoked - dependencies and a changed manifest digest. The resolution order is fixed - and recorded in the lock. -- Materialize the signed Linux base, then each runtime layer in locked order, - then the application. For the first format, reject overlapping file paths - rather than allow a later layer to silently replace an earlier one. Refuse - symlink escapes, special files, missing dynamic loaders, unbounded archives - and unverified bytes. Keep build tools out of the final rootfs unless the - workload explicitly needs them. -- Exactly one application owns the entrypoint. Runtime layers may contribute - binaries, libraries and declared logical requirements. Compose policy by - field using the existing policy rules: requested allows combine, denials - prevail, resource ceilings take the minimum, and secret destinations may - only narrow. Check the signed application ceiling and intersect the request - with host-authorized grants at admission; a pack's request never grants itself - access. The host remains the authority for actual grants and secret values. -- Neither development nor production has a guest NIC, SSH, port forwarding, - guest NAT or a network fallback. Any permitted egress uses the authenticated - host-mediated FlowMux path. Development may request a user-authorized - writable workspace; production cannot inherit that request or install - dependencies on first boot. -- The final `.mvmpkg` contains everything needed for offline verification and - boot: the selected kernel and initramfs, the composed rootfs and verity - material, its signed environment and provenance, and all required runtime - bytes. A registry descriptor separately pins that exact archive. Runtime - layers can be cached and reused while building, but the recipient need not - have those layers in its cache to run the final archive. - -## Missing pieces - -The v2 authored contract accepts only a local source today; its TOML parser -does not lower to Workload IR or write/read a frozen lock in the CLI. Its -runtime requests can be bound to authenticated, ordered descriptors and -checked again before -base-plus-runtime candidate composition. The v1 pack lowering still -selects Nix packages. The frozen v1 lock pins a compiler revision, not a -graph of independently authenticated layers. `mvmctl pack build` currently -boots a Nix-built slot and its exported environment has no pack policy, secret -slots, volumes or capabilities. Authenticated runtime-layer metadata and -external archive and declared policy bytes can be verified in source, and -authenticated dependency pins can be ordered with cycle, architecture, -digest, duplicate-name and revocation checks. A build-only acquisition API now -accepts an exact caller-supplied pin, publisher-authenticates the descriptor, -requires fresh revocations, and holds the signed archive and policy bytes in -private snapshots. Its first remote transport accepts only mvm-packs HTTPS -GitHub release assets; the signed digest, not the release URL, makes their -contents immutable to the builder. OCI and other HTTPS origins fail closed until a -separately reviewed transport exists. For the first Python slice both local -and remote build-input acquisition refuse anything but a signed deny-all group -with `required = true`, no profile, network routes, secrets, shares, tools, environment overrides -or resource requests. This is a first-slice restriction, not the general -policy-intersection algorithm, and host admission must independently enforce -it before a package can run. It neither freezes a v2 CLI lock nor -installs an image: `pack registry pull` and the installed-pack reader still -refuse a runtime layer. A signed base tar can be fetched separately -from the boot artifacts and merged with runtime archives in order without -replacing base-owned files, retaining tar ownership under one aggregate size -and entry budget. A digest-bound application archive can be added under -`/srv/app` and emitted as an ext4+verity candidate. Its resolved input record -is not a cache install or a runnable bundle: source custody, symlink closure, -policy, release proof and health boot still require final checks. -The proposed bundle schema 8 is reserved for one signed composable-application -member that binds runtime manifest pins for transitive revocation checks. -Coordinate this floor with `mvm-packs` ADR 004 before landing the public -format; its later policy-only kind, builder-version and in-file keyless -signature/provenance changes have no assigned floors yet. New composable -exports use schema 8 only: no legacy-output option, version downgrade or -fallback that drops composition evidence. Existing Nix/v1 builds and older -bundle readers remain unchanged; removing them is a separate migration, not -a prerequisite for this format. Carry one typed composition member, not -reuse the v7 `other` role as an implicit grant. Its Ed25519-signed manifest -binds the authored spec/source/producer lock, selected signed Linux root, -ordered runtime manifest pins, policy digests, output artifact hashes and -health-tested entrypoint. Embed each -runtime's original keyless-signed descriptor, signature bundle and declared -policy document as small digest-bound bundle artifacts; a clean offline -recipient must verify both publisher and bundle trust roots and check -current revocations for every pinned layer before admission. The final -package carries the composed rootfs and CLI runtime itself, not an external -layer fetch dependency. Missing evidence, ambiguous policy, changed -bytes or an old-schema bundle claiming composition must refuse, never fall back -to an unsigned local cache or guest install script. This is a source -contract, not a completed exporter or consumer. -The CLI must not interpret any new source, mode or layer field as optional -and silently fall back to Nix. A local production-mode build is not an -official published release merely because it is sealed and host-signed. -Both local and official schema-8 packages use the same verifier and archive -format. Every schema-8 install or run requires a trusted bundle signing key: -local keys require explicit operator enrollment, even on their build host; -an official key must be enrolled or part of authenticated built-in trust. -A direct non-packaged development build need not install a package. -Official release admission additionally authenticates the publisher identity -and exact distribution pin. Local admission remains operator-approved; it -never claims release verification just because the host signed or enrolled -a key. The current local-build development auto-trust and production refusal cannot -authorize schema-8 packages without a distinct admission rule. Keep current -signed revocation evidence independent of the archive's self-contained boot -bytes; missing or stale trust state refuses offline admission. Reuse one -composition and verifier path rather than adding a legacy compatibility layer. - -The format owner is `mvm`, coordinated with the `mvm-packs` publisher owner. -The registry producer and consumer must learn the new layer kind together -before any layer is advertised as supported. Do not mutate existing -application or policy-only manifests to imitate it. - -## Spike and decision rule - -The **unsigned** per-architecture Python source experiment is -[`scripts/build-python-runtime-layer.py`](../../scripts/build-python-runtime-layer.py). -It accepts only the architecture-selected, SHA-256-pinned -`cpython-3.13.18+20261026` musl static-full tar from the Astral -python-build-standalone release: x86_64 source -`e0bb0840e127aa95dbb1212202a29cde715711ae6826132e548f5eb04a958533` -or aarch64 source -`9968a4b1fc3db172ba3b3ab36235fa76fab91c3ec83204f269adca756c87261d`. -It repacks the static interpreter and runtime-only standard library as a -deterministic plain `layer.tar`; it does not run untrusted install scripts, -sign, publish, or grant policy. The selected ELF machine must match the -target. The x86_64 interpreter has no ELF `PT_INTERP` loader. -Independent macOS and Linux x86_64 runs produced SHA-256 -`9f5dfaf983a54a62212184f08f5dfc36d1474052d71a799cdfd3f1a53dc970d4` -and 63,836,160 bytes. Two local macOS aarch64 runs produced SHA-256 -`2c65e1c400098a3a8141a58f2f2f66a3df667d5f947d86d982f3d115b023b0f2` -and 64,798,720 bytes; independent Linux reproduction and native boot remain -unwitnessed. The signing smoke pins both architecture outputs. -The upstream GitHub asset digest establishes *byte identity*, not an -independent publisher-signature or reproduction claim. A production publisher -must accept that upstream trust choice explicitly, sign a distinct immutable -descriptor per architecture binding its actual output SHA-256 and size, -and pass the consumer and exact-archive boot gates. The current schema binds -one architecture per version; do not publish both under the same coordinate -without a new, reviewed multi-architecture index contract. The historical revoked -policy-only `runtime/python@1.0.0` is not reusable. - -Keep the Nix producer working unchanged. Resolve one authenticated Python -runtime input into a runtime layer, add a small Python application as a distinct -input and produce one candidate with one entrypoint without invoking Nix in the -package-build path. Compare against the current Nix path using the same -application and target: cold and warm elapsed build time, fetched bytes, cache -reuse, generated archive size, and guest boot. Report the input acquisition -time separately from filesystem assembly and signing time. - -Choose this producer path for the next package class only if it boots the -exact self-contained archive, preserves all trust and isolation checks, and -improves measured end-to-end build time or complexity without an unacceptable -size regression. Otherwise retain Nix for that class and identify the costly -stage before adding a different producer. The accepted build-time composition -decision does not depend on Buildroot winning the separate base-image test. - -Before calling the path production-ready, reproduce the unsigned payload -bytes in independent builds, verify the pinned base and every runtime input, -boot the composed image and execute Python from the final archive on both -supported architectures, then demonstrate refusal on tampered layers, missing -loaders, stale locks, conflicting paths, widened policy and a changed tested -artifact. Signing timestamps and externally supplied signatures are not -payload reproducibility evidence. Do not infer publication or a supported -release from source-only tests. - -## Open questions - -- Future Linux sets add a deterministic, signed `rootfs.tar.gz` artifact - alongside each production default/rootless ext4 member. The producer makes - both from the same fakeroot tree; the existing 17 members and historical ext4 - bytes are unchanged. The consumer selects the tar only through the verified - set root and refuses missing bytes or changed digests. Reproduction of - published bytes and signed-set admission still need native CI evidence; - the ext4 tree-diff reader cannot substitute for this tar without losing - ownership and xattr fidelity. -- Linux Docker builds produced both default and both rootless base tars - (native aarch64, emulated x86_64) from the local Nix definitions. - The default tars materialized in Rust on macOS to ext4+verity candidates - with zero file, path or mode differences from their respective Nix ext4s. - The rootless tar contains case-distinct paths that APFS cannot stage - faithfully: Rust refuses it instead of signing a changed image. Route - rootless (and any similarly colliding runtime) through a case-sensitive - Linux execution boundary. The emulated x86_64 build needed Nix's inner - syscall filter disabled for local validation; neither Docker build is - release reproduction or boot evidence. -- Requested egress composition and host grant semantics have conflicting - descriptions in the pack ADR, PRD and merge guide. The effective policy - needs one tested, signed, fail-closed rule before production composition. -- The spike determines whether a pinned, signature-verified OCI Python - userspace is both faster and smaller enough than the current Nix closure. - Native Claude payload redistribution is a separate permission and - loader-compatibility gate, not a prerequisite for the Python spike. +# Contract — portable application composition + +Status: release-candidate design, **not** an accepted or published feature. +See `mvm-packs` ADRs 004–006. This contract replaces the fixed-default-base, +single-application assumptions of the first source-only candidate without +changing any published pack or `mvm.pack-spec/v1` semantics. + +## What is independently reusable + +A portable **application pack** carries signed, digest-bound application bytes, +declared paths, immutable identity, requirements, policy and optional +entrypoint. It contains neither a kernel nor an independently bootable rootfs. +A **runtime/tool pack** carries separately signed software and policy; a +runtime layer has one publisher-authenticated registry descriptor binding its +external archive digest and size, **not** a second Ed25519 archive signer. +Neither kind can boot alone. Existing schema-2 application descriptors still +point at complete, independently signed `.mvmpkg` boot bundles, including the +published Claude versions; legacy policy-only versions remain immutable and +non-runnable. Keep both the existing v1 release reader and v1 authoring while +the new format reaches parity. Unknown versions fail closed. + +The Linux **base** supplies the kernel and a generic, verifiable filesystem +composition source; `mvm` supplies the separately signed complete guest +runtime/overlay/initramfs. An app must work unchanged with either our default +or a compatible user-built base. `mvm-images` publishes one optional default +base on its own cadence, never per-app images or mvm runtime. Its unpublished +schema-3 signed `rootfs.tar.gz` is a composition source, not a second rootfs +or runtime role. The last known signed set, v0.2.4, lacks that tar; do not +advance `images.lock` or publish a set without the released-consumer gates and +specific authorization. + +## Selection, compatibility and authority + +`mvmctl run agent/claude` stays a single command with a fast compatible +default. An explicitly ordered composition with `--base` is also a single +command. The selected base, not a fixed compiled-in default, is an input to +**one** resolver, assembler, cache and admission path. Do not retry with the +default when a selected base is incompatible or untrusted. + +A bounded, versioned selected-base record must bind its trust class, +architecture, boot protocol, kernel digest/size, composition filesystem +digest/size, base-image contract, filesystem ABI, minimum kernel features, +libc/loader compatibility, capability set, and independently signed guest +runtime evidence. Check the actual bytes and compatibility of all selected +roles. A development record may bind a user-built base with measured local +evidence; it must be marked local and cannot be relabelled, indexed or +admitted in production. Production requires independently trusted signed +base and runtime evidence, fresh revocations and a protected verifier; a +host-signed build is never official publisher authority. An unsigned or +incompatible production base is rejected with no fallback. Do not accept +unsupported special files, symlink escapes or an inconsistent base ext4/tar +pair merely because their declared digests are well formed. + +Schema-8+ host-signed local bundles that can be installed or launched later +require explicit operator enrollment under mvm PR #5301. Same-host signing +alone is not operator enrollment or release trust. For one-command first use, +the chosen development path is a separate, one-use, Dev-only transient +admission proof (#5341), bound to the exact host-signed archive and its +reverified base. It never enters the installed registry or publisher trust +store, and a repeat run must mint a new proof after rechecking cached bytes. +The current source candidate stages a host-verified archive in an +invocation-private directory, carries one Dev admission attempt, and +reverifies it before boot. It does not put that archive in the installed +registry; the composition cache holds bytes, not launch authority. This +is not accepted until cold/repeat CLI guest output and refusal tests pass. +Neither route grants production/export authority; the separately signed +and fully bound production composition remains the long-term direction. + +`mvm.pack-spec/v2` currently parses one local application, ordered `runtime/*` +requests, `/srv/app` copies, literal argv and resources. Its `[production]` +table expresses intent only: it neither signs nor authorizes a release. +The development-only CLI candidate accepts `mvmctl run --compose PACK.TOML` +with the signed default or an explicit local manifest/kernel/tar selection. +The local selection carries distinct schema-9 same-host proof, not canonical +publisher authority. The separate `base-compatibility/v1` descriptor verifier +binds exact base bytes and compares component requirements, but no signed +production descriptor is published or acquired by this CLI path. A new +versioned multi-component request must connect the existing v3 signed graph, +verified schema-3 application/schema-4 runtime and tool inputs, and entrypoint +choice without changing the old v2 recipe's meaning. An unpinned production +input, an unknown requirement, or a production request without separately +trusted base evidence fails closed. + +`mvm.composed-run/v1` is the proposed one-file v3 invocation envelope: it +requires a Linux target, the `mvm.pack-spec/v3` ordered graph, and an +`mvm.entrypoint-request/v1` command selection. Base selection and host grants +are separate launch inputs; this authored envelope grants neither. Unknown +fields, including implicit service startup, are refused. The contract parses +and validates the envelope, but `mvmctl run --compose` does not consume it yet. + +## Resolution, filesystem and cache + +Resolve an **explicit ordered graph** of publisher-authenticated, +exact-version-and-manifest-digest-pinned components. Resolve dependency pins +before assembly; refuse duplicates, cycles, missing trust, revoked inputs +and arch/ABI/libc/kernel/capability mismatches. Authenticate descriptor +signatures and snapshot every external archive and signed policy file against +the descriptor's digest/size before unpacking. Check fresh, rollback-resistant +revocation feeds on every run, including cache hits. + +Stage the selected base first, then locked runtime/tool components and +applications in their explicit order. Reject collisions, including a leaf +against a base directory, permission or ownership changes, whiteouts and +overlaps with host-owned guest paths. No component silently replaces a file, +mode, symlink or parent. No guest install script, boot-time package fetch or +implicit service start. Each application keeps its commands available by +name, but only the selected entrypoint executes. + +Build and measure one ext4/verity workload **before first boot** and cache it. +The filesystem cache key is a versioned hash of the exact selected base +composition bytes, ordered component archive digests and declared filesystem +ABI, and assembler version. Do not key those bytes on the effective policy, +host grants or command if they are not written into the filesystem. If any +such data *is* embedded in the filesystem, it is explicitly hashed as an +assembly input. The v2 `run --compose` candidate bakes its literal authored +argv as a sealed `/etc/mvm/entrypoint` script, so its script digest belongs +in the filesystem key; changing that command rebuilds the filesystem. +Reverify cached bytes and their verity binding before use; +the cache is not a trust decision. First-run assembly, repeat boot, transfer +and retained disk must be measured separately. Reuse the candidate's +host-signed local-bundle/schema-8 path, not another composer or cache. + +Entrypoint precedence is: **runtime override > user-defined workload/config +entrypoint > last application in explicit order declaring an entrypoint**. +“Last wins” applies only to command selection. The selected command is +separately admitted under the selected security profile and host-authorized +grants, even for production overrides. Record and audit the actual command +executed, not merely a declared default. Signed component policy requests +cannot grant themselves network, secrets or shares: denies win, ceilings +narrow, and effective requests intersect host grants. Resolve and test policy +in one authoritative path. + +## Launch and export + +Bind selected base identity and trust class, signed guest runtime, +ordered component manifest/payload/policy hashes, measured output, +effective policy, grants and selected *actual* command to the existing +signed bundle and signed execution plan. Host admission rechecks input +publisher trust, freshness and rollback of revocations, measurements, +effective policy and grants **immediately before launch**, even on a cache +hit. Missing or conflicting evidence refuses launch. Local composition is +always marked development-only. + +For production, builder-vm materializes candidates without release +credentials. A separate protected pipeline independently verifies the +frozen graph and selected boot closure, checks the tested output and command, +and signs one self-contained `.mvmpkg` carrying kernel, initramfs, composed +ext4/verity, signed runtime evidence and full input provenance. The recipient +verifies it independently and applies current revocation and grant rules. +Neither a local receipt nor a host signature can stand in for protected +publisher signing. PR-only synthetic signing is allowed for tests but does +not enroll a production trust root or authorize publication. The prospective +`mvmctl pack publish ./my-pack` is a separate protected-publishing slice, +not an alias for local export. + +There is **no guest NIC, TAP/TUN, NAT, SSH, CNI, SLIRP or fallback**. Permitted +networking uses only host-mediated, authenticated FlowMux/vsock. + +## Approved warm custody direction (implementation not evidenced) + +The existing persistent-machine start sets `warm_pool_size(0)`; the factory +parent refuses both PR-test runtime proof and kernel descriptor custody. +Neither changing that value nor trusting a signed cache index makes a named +composition safe to claim. The current `TrustedSnapshotBackend` and +`TrustedSnapshotService` already separate immutable publication from claim, +but the default backend refuses, APFS is opt-in and needs a privileged helper, +and `materialize` alone does not hand an immutable read view to the VMM. +These are implementation boundaries, not a warm capability claim. + +The service now reserves each ID before staging, issues a fresh generation +per sealed publication, and holds a per-ID claim lease through materialization +so a concurrent removal cannot reclaim its backend read view. Failed staging +or cleanup leaves the ID reserved rather than handing it to another publisher. +After a failed removal, claims stay blocked and only the owning handle can +retry cleanup. +These in-process lifecycle guards do not themselves seal bytes, survive an +owner crash, or authorize a VM to boot from a writable materialization. + +Extend that **one** publication/claim contract rather than add a composer +authority or accept a mutable path as proof. Before any claim, a resident owner +must authenticate and publish the exact selected kernel, rootfs, runtime and +factory-parent bytes under a platform-enforced immutable read view, with +their digest, provenance, backend boot shape and compatibility key bound to +the parent. The parent holds no tenant plan, secrets, writable volumes or +egress destinations. A Linux provider needs an evidenced sealed descriptor +or equivalent kernel-backed immutability; macOS needs the authenticated APFS +helper and live mount/tamper witnesses. Unsupported hosts refuse warm +publication instead of quietly claiming from an unsealed pool. + +Each child still needs fresh revocation, policy, grant and one-use admission +checks **before** the admitted plan and child-owned effects can reach the +guest. A claim consumes a generation-checked parent only when its immutable +closure matches that plan; restart, expired evidence, changed inputs and an +unavailable owner refuse rather than treating a cache hit as authorization. +The VMM must map the verified immutable read view, not a newly writable +materialization that needs claim-time rehashing. Bound resident parents and +leases by the existing host budget, reclaim them only after the last child +releases custody, and test cancellation and owner crash recovery. + +The simpler alternatives do not meet this contract: per-start restaging and +rehashing stay cold, while a host-signature/HMAC sidecar cannot seal the bytes +it names. Before enabling either backend, review the owner/descriptor API and +measure cold and warm *readiness including admission*, plus first-use +construction separately. The required thresholds remain <300 ms cold and +<50 ms warm; neither is evidenced by the current CLI timings. + +A diagnostic `MVM_SPAN_TIMINGS=json` run of the native arm64 HVF +`release-witness` CLI measured 9.28 s for a named start after preparation: +`prepare_archive` took 0.86 s even on an authenticated cache hit, +invocation-private `stage_with` took 2.16 s, `consume` 0.44 s, +`start_persistent_oci_machine` 4.63 s (including 0.45 s of plan admission), +and retaining boot custody after launch 0.44 s. These are nested spans, not +additive totals. They show that moving the cache lookup ahead of filesystem +staging is useful but cannot by itself meet either launch threshold; private +staging, persistent boot preparation and admission remain on the hot path. A +second native arm64 HVF run measured 8.94 s including 3.62 s for persistent +boot preparation (2.95 s self) and 0.99 s for legacy boot completion (0.93 s +self). This is diagnostic timing, not an arm64 Linux/KVM witness. + +### Approved read-view direction (implementation not evidenced) + +The Pi's kernel does not advertise `CONFIG_FS_VERITY`, so a Linux +fs-verity-only provider would fail closed there. A test-only direct-Firecracker +probe copied the PR kernel (8.5 MB) and ext4 rootfs (71.3 MB) into separate +`MFD_ALLOW_SEALING` memfds, applied the write, grow, shrink and further-seal +prohibitions, confirmed a later write was refused, and passed +the descriptors to pinned Firecracker as `/proc/self/fd/N` paths. Firecracker +accepted both paths, started under KVM without a guest NIC, and its console +showed the Linux kernel mount the root ext4 read-only and run `/bin/sh` as +init. This was **only** an immutable-FD mechanism probe: it bypassed mvm +admission, the scoped launcher, signer custody and the normal guest runtime. + +Use one typed publication and claim API, with sealed memfds for small Linux +boot inputs and explicitly budgeted warm parents. A Linux host that supports +fs-verity may instead hold inode-pinned read descriptors for large rootfs +bytes, bound to the exact authenticated content and verity identity; the Pi +cannot use that provider. Reflinks and permission bits are not immutable +read views. Account memory, descriptor count and resident parent shapes +before publication, and refuse a requested warm claim when neither provider +can seal its exact closure within those budgets. A normal cold start remains +separately admitted; it is never reported as a warm claim. + +The existing `TrustedSnapshotBackend` and `TrustedSnapshotService` must +return an owned read lease, not a writable destination. Its generation and +exact signed boot closure bind the kernel, rootfs, runtime and parent shape; +the already-used Firecracker descriptor handoff must verify the VMM's +actual objects and retain the lease until the last child exits. Each child +still gets fresh revocation, policy and one-use plan admission. Owner restart +loses memfds and must reconstruct from authenticated inputs before any new +claim. APFS remains unsupported for this claim until a separately evidenced +read-only mount lease exists. None of this is enabled by the direct-VMM probe +or by the in-process snapshot service alone. + +## Acceptance and currently missing evidence + +The staged v2 candidate authenticates runtime descriptors/policies and a +signed-base composition tar, rejects path collisions, and creates a +host-signed local development bundle for a default or explicitly selected +local base. A verified host-signed filesystem may be reused across command +choices only when no command bytes are baked into it and its ordered +filesystem identity is unchanged; current trust, +revocations and grants must still be readmitted. Typed v3 component readers, +an exact-pin acquisition helper, ordered staging and measured filesystem-only +lock, a separately signed base-descriptor verifier, and an authenticated +policy-snapshot adapter exist. The filesystem lock grants no command or host +authority; v3 is not wired into the CLI invocation, admission or production +export. +Direct-QEMU synthetic boots read `COMPOSED_GUEST_VALUE=print('ready')`, but +they are not CLI admission evidence. On PR #5326 at `4d9e5d21ad`, the +`release-witness` CLI and same-head signed PR base/runtime artifacts from +Actions run `43172747569` produced that value through `mvmctl run --compose` +on x86_64 Firecracker: 11.19 s on a cold composition, 8.52 s on an authenticated +cache hit. Both exited zero without enrolling a publisher; neither meets the +<300 ms cold or <50 ms warm launch target. At `4b95627ef9`, the same-head +PR-only signed CLI, x86_64 base and guest runtime from Actions run +`43284492364` also produced `COMPOSED_GUEST_VALUE=print('ready')` through +`machine create packed-fixed --pack ...`, `machine start packed-fixed` and +`machine logs packed-fixed` on Firecracker. Create took 4.70 s, the first start +11.03 s and a stop/restart with an authenticated composition cache hit 8.77 s. +These are named-machine guest-output witnesses, not speed acceptance; aarch64 +Linux/KVM and immutable warm custody had not yet been witnessed at that commit. +On macOS arm64 HVF, a native `release-witness` CLI with the same PR-signed +aarch64 base and guest runtime assembled a named composition in 3.51 s and +produced `COMPOSED_GUEST_VALUE=ready` through a 9.74 s start and a 9.77 s +stop/restart cache hit. Its CLI was built locally rather than attested by the +PR workflow; this is a native HVF guest-output witness, not Linux arm64/KVM +or launch-speed acceptance. + +At `02a5f6ee3c`, a locally cross-built arm64 Linux `release-witness` CLI and +host helpers, hash-checked after transfer, used the same-head PR-signed +aarch64 base and runtime from Actions run `43314770069` on a Raspberry Pi 4 +running Linux 6.18.50 with accessible `/dev/kvm` (KVM API version 12). +The CLI created a named machine from the pack and local base in 20 s. Pinned +Firecracker v1.17.0 booted it via `machine start arm-witness` with explicit +`--hypervisor firecracker`; `machine logs` read `COMPOSED_GUEST_VALUE=ready` +after 53 s. Stop and restart produced the same guest value in 53 s; the +repeat Firecracker API log contained no `network-interfaces` request. The +console log is not hash-chained; the CLI is locally built rather than +release-attested. This demonstrates +an arm64 Linux/KVM guest read, **not** immutable warm custody, launch-speed +acceptance, or a production release. + +Acceptance requires Linux **CLI** one-command default and selected-custom-base +cold/repeat runs, a value actually read from the guest on both runs, a proven +filesystem cache hit, and rejection of tampered, revoked, stale, conflicting +and incompatible inputs. Preserve the direct-VMM checks as additional +evidence. Measure assembly and boot times, transferred and retained bytes; +run formatting, Clippy, invariant/footprint and registry gates. Synthetic +applications avoid Claude redistribution. No compatible published base tar +or released CLI/full-runtime closure yet exists; no CI result alone overrides +that publication prerequisite. diff --git a/tests/fixtures/composable-cli-value/pack.toml b/tests/fixtures/composable-cli-value/pack.toml new file mode 100644 index 0000000000..48a92f8774 --- /dev/null +++ b/tests/fixtures/composable-cli-value/pack.toml @@ -0,0 +1,21 @@ +# Synthetic development-only guest-output witness; no Claude or runtime layer. +schema = "mvm.pack-spec/v2" +target = "x86_64-linux" +runtimes = [] +entrypoint = ["/bin/cat", "/srv/app/value.txt"] + +[identity] +name = "agent/example" +version = "1.0.0" + +[source] +kind = "local" +path = "." + +[[copy]] +source = "value.txt" +destination = "/srv/app/value.txt" + +[resources] +cpu_cores = 1 +memory_mb = 256 diff --git a/tests/fixtures/composable-cli-value/value.txt b/tests/fixtures/composable-cli-value/value.txt new file mode 100644 index 0000000000..56c081bbb2 --- /dev/null +++ b/tests/fixtures/composable-cli-value/value.txt @@ -0,0 +1 @@ +COMPOSED_GUEST_VALUE=print('ready') diff --git a/tests/fixtures/composable-images-source.ref b/tests/fixtures/composable-images-source.ref index c981a2ccd1..9835886205 100644 --- a/tests/fixtures/composable-images-source.ref +++ b/tests/fixtures/composable-images-source.ref @@ -1 +1 @@ -8c947dc9ac15741e10b627540dbd2641259ab8c3 +34da739c12a75d56972d7e527a419124f3e3fac4 diff --git a/xtask/src/check_bundle_trust_sites.rs b/xtask/src/check_bundle_trust_sites.rs index fb1ab506ec..09014d29ee 100644 --- a/xtask/src/check_bundle_trust_sites.rs +++ b/xtask/src/check_bundle_trust_sites.rs @@ -33,6 +33,7 @@ const MUST_COMPOSE: &[&str] = &[ "crates/mvm-client/src/admission/pinned_bundle.rs", "crates/mvm-client/src/launch/bundle_runtime.rs", "crates/mvm-client/src/launch/canonical_linux.rs", + "crates/mvm-client/src/launch/transient_composition.rs", "crates/mvm-runtime/src/vm/template/lifecycle/snapshot.rs", "crates/mvm-cli/src/pack_registry.rs", "crates/mvm-cli/src/commands/pack/inspect.rs", diff --git a/xtask/src/check_file_size.rs b/xtask/src/check_file_size.rs index 6462620fd6..b1418a0593 100644 --- a/xtask/src/check_file_size.rs +++ b/xtask/src/check_file_size.rs @@ -25,7 +25,6 @@ const MAX_PROD_LINES: usize = 1500; const GRANDFATHERED: &[(&str, usize)] = &[ ("crates/mvm-builder-guest/src/bin/mvm-host-vm-init.rs", 3637), ("crates/mvm-build/src/libkrun_builder.rs", 4485), - ("crates/mvm-cli/src/commands/machine/mod.rs", 1733), ("crates/mvm-cli/src/commands/ops/audit.rs", 1649), ("crates/mvm-hostd/src/plan_admission.rs", 2256), ("crates/mvm-runtime/src/backends/hvf/kernel_boot.rs", 2208), diff --git a/xtask/src/check_rust_footprint.rs b/xtask/src/check_rust_footprint.rs index 67fd8094fe..b8f2804395 100644 --- a/xtask/src/check_rust_footprint.rs +++ b/xtask/src/check_rust_footprint.rs @@ -34,12 +34,13 @@ use std::process::Command; use crate::rust_source::{CommentSyntax, blank_comments}; -// CI witness repair adds checked timeout and enrollment regressions; admit its -// small measured increase rather than delete security coverage to fit the ratchet. +// Main's CI witness repairs and the portable composition/named-machine paths +// measure 976,881 SLOC and 1,243,243 physical lines before this sync. Keep +// the ceiling close to the measured tree without dropping security coverage. /// Ceiling on total SLOC, all categories. -const MAX_TOTAL_SLOC: usize = 966_100; +const MAX_TOTAL_SLOC: usize = 977_500; /// Ceiling on total physical lines, all categories. -const MAX_TOTAL_PHYSICAL: usize = 1_232_000; +const MAX_TOTAL_PHYSICAL: usize = 1_244_000; /// How far under a ceiling the tree may sit before the ceiling must drop. const SLACK: usize = 10_000; diff --git a/xtask/src/check_two_surfaces.rs b/xtask/src/check_two_surfaces.rs index 5117185dbf..7cdb1547e1 100644 --- a/xtask/src/check_two_surfaces.rs +++ b/xtask/src/check_two_surfaces.rs @@ -26,7 +26,7 @@ const SURFACES: [&str; 2] = ["host", "user"]; /// forwards are inlined in the root Cargo.toml); what remains here is `default` /// plus platform/storage opt-ins that genuinely cannot always be on. Keep this /// list tiny — a new entry is a conscious "this cannot live in a surface". -const INTERNAL: [&str; 10] = [ +const INTERNAL: [&str; 12] = [ "default", // Platform-native caller custody; included by the user product surface. "native-caller-identity", @@ -47,6 +47,10 @@ const INTERNAL: [&str; 10] = [ // Dev/test-only: the hermetic in-memory mock VM backend. Never part of a // shipped binary; only this workspace's own test suite turns it on. "test-support", + // PR-only signed runtime fixtures; normal and release-channel builds refuse them. + "pr-test-guest-runtime", + // PR-only authenticated base fixture; its output remains host-local Dev. + "pr-test-synthetic-base", ]; /// A couple of release/build-only flags are allowed but explicitly *not* part of