diff --git a/.github/actions/build-release-artifacts/action.yml b/.github/actions/build-release-artifacts/action.yml index 3f76d28..e62ba80 100644 --- a/.github/actions/build-release-artifacts/action.yml +++ b/.github/actions/build-release-artifacts/action.yml @@ -126,6 +126,13 @@ runs: echo "uefi=$dir/pvkernel-${suffix}-uefi.img" >> "$GITHUB_OUTPUT" echo "suffix=$suffix" >> "$GITHUB_OUTPUT" + # Boot the exact production image copied into the publication directory. + # No test features or rebuilt substitute are used by this smoke check. + - name: Boot packaged release image + uses: ./.github/actions/release-boot-smoke + with: + image: ${{ steps.locate.outputs.dir }}/pvkernel-${{ steps.locate.outputs.suffix }}-uefi.img + - name: Generate checksums shell: bash # Computed last, after every other artifact (including the SBOM) is diff --git a/.github/actions/release-boot-smoke/action.yml b/.github/actions/release-boot-smoke/action.yml new file mode 100644 index 0000000..9cc1ee1 --- /dev/null +++ b/.github/actions/release-boot-smoke/action.yml @@ -0,0 +1,22 @@ +name: Boot production release image +description: Boot the supplied, unchanged UEFI image and require the real PID 1 CPL3 marker. +inputs: + image: + description: Path to the production UEFI disk image (built without test features). + required: true +runs: + using: composite + steps: + - name: Install QEMU and OVMF + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends qemu-system-x86 ovmf + - name: Verify production image boots to PID 1 + shell: bash + env: + RELEASE_IMAGE: ${{ inputs.image }} + run: | + python3 -I scripts/boot_release_image.py "$RELEASE_IMAGE" \ + --ovmf-code /usr/share/OVMF/OVMF_CODE_4M.fd \ + --ovmf-vars /usr/share/OVMF/OVMF_VARS_4M.fd diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e71dde..30c7711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,47 @@ jobs: - name: Clippy (pvkernel-runner, host target) run: cargo clippy -p pvkernel-runner --locked -- -D warnings + host-tests: + name: Host filesystem and release tooling tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pinned toolchain + run: rustup show + - name: Ensure PyYAML is available + run: | + python3 -c 'import yaml' 2>/dev/null \ + || sudo apt-get install -y --no-install-recommends python3-yaml + - name: Test filesystem primitives on the host + run: cargo test -p pvkernel-fs-core --locked + - name: Test release and policy tooling + run: python3 -I -m unittest discover -s scripts/tests -v + - name: Validate deterministic scenario coverage + run: python3 -I scripts/check_qemu_coverage.py + + release-boot: + name: QEMU / release image boot + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pinned toolchain + run: rustup show + - name: Build production release image + id: image + run: | + set -euo pipefail + cargo metadata --format-version 1 --no-deps --locked > "$RUNNER_TEMP/metadata.json" + package_id=$(python3 scripts/cargo_release_out_dir.py package-id "$RUNNER_TEMP/metadata.json") + cargo build --release --locked --message-format=json-render-diagnostics | tee "$RUNNER_TEMP/build.jsonl" + out_dir=$(python3 scripts/cargo_release_out_dir.py out-dir "$package_id" "$RUNNER_TEMP/build.jsonl") + echo "path=$out_dir/pvkernel-uefi.img" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/release-boot-smoke + with: + image: ${{ steps.image.outputs.path }} + build: name: Build (${{ matrix.features }}) runs-on: ubuntu-latest @@ -110,6 +151,18 @@ jobs: - scheduler-preemption-test - task-stack-guard-fault-test - task-capacity-test + - process-preemption-test + - process-smoke-test + - process-exit-test + - process-id-reuse-test + - user-copy-test + - user-fault-containment-test + - address-space-isolation-test + - process-capacity-test + - user-stack-guard-fault-test + - user-kernel-isolation-test + - elf-loader-test + - elf-malformed-test - initrd-boot-test - initrd-missing-test - initrd-corrupt-test @@ -374,3 +427,30 @@ jobs: description: >- Fills the fixed task table, checks deterministic exhaustion, reuses an exited slot with a new generation, and rejects the old ID. + + qemu-process: + name: QEMU / process regression (${{ matrix.features }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - process-preemption-test + - process-smoke-test + - process-exit-test + - process-id-reuse-test + - user-copy-test + - user-fault-containment-test + - address-space-isolation-test + - process-capacity-test + - user-stack-guard-fault-test + - user-kernel-isolation-test + - elf-loader-test + - elf-malformed-test + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/qemu-scenario + with: + features: ${{ matrix.features }} + description: Process, address-space, user-copy, fault, and ELF regression coverage. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ee8084..f090163 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,8 +94,8 @@ jobs: run: | set -euo pipefail version="${{ steps.version.outputs.version }}" - if ! grep -qF "## [${version}]" CHANGELOG.md; then - echo "::error::CHANGELOG.md has no '## [${version}]' entry" >&2 + if ! grep -qxE "## \[${version//./\\.}\] - [0-9]{4}-[0-9]{2}-[0-9]{2}" CHANGELOG.md; then + echo "::error::CHANGELOG.md must have a dated stable entry for $version (Unreleased is not accepted)" >&2 exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 133d295..6063078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ ### Changed +* Release correctness gates now include all 34 deterministic QEMU scenarios, + host filesystem tests, and release-tooling tests, with a coverage guard + against omitted Cargo scenarios. +* CI and both release pipelines boot production release-profile UEFI images; + packaging verifies the exact image to be published before hashing and + attestation. Stable releases also require a dated changelog entry. + * Ordinary boot resolves the boot init through `Boot:`; it no longer launches the kernel-generated userspace smoke ELF. * User-process ELF construction and publication is centralized in diff --git a/ROADMAP.md b/ROADMAP.md index 4905eea..3c9a561 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,12 @@ remain future work. * bootloader-delivered CPIO `newc` initrd registered as the temporary `Boot:` volume * `Boot:\\pvOS\\System\\Init` loaded through the existing static ELF64 policy as PID 1 * deterministic parser/path/provider/volume/QEMU validation +* release validation covers all 34 deterministic QEMU scenarios and host + filesystem tests, plus boot verification of the packaged production image + +Release preparation: validate the corrected gates in RC3 before the stable +0.0.10 release. Persistent storage and additional userspace APIs remain +outside this milestone. ## 0.1.0 — First pvOS-Ready Kernel Foundation diff --git a/docs/development/testing.md b/docs/development/testing.md index ac1e244..cddbc75 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -150,7 +150,39 @@ documented in [docs/architecture/kernel-tasks.md](../architecture/kernel-tasks.m ## CI integration -`.github/workflows/ci.yml`'s `qemu-tests` job installs `qemu-system-x86` and -runs the timer, fault, memory, and userspace tests described above on every -pull request and push to `main`, in addition to the `check` job's -formatting and build verification. +`.github/workflows/ci.yml` reports formatting, Clippy, host filesystem/tooling +checks, feature builds, and each deterministic QEMU scenario separately. +The 34-scenario runtime matrix includes all process, ELF, address-space, +user-copy and fault-containment regressions as well as the initrd scenarios. +`scripts/check_qemu_coverage.py` fails if a standalone Cargo test feature is +missing from that runtime matrix, preventing silently incomplete green runs. + +Host filesystem tests run with `cargo test -p pvkernel-fs-core --locked`; +this selects the architecture-neutral crate without combining the bare-metal +kernel and host runner test roots. Python tooling tests run with +`python3 -I -m unittest discover -s scripts/tests -v`. + +### Production release image boot + +The additional `QEMU / release image boot` job builds with +`cargo build --release --locked` and no test features. Cargo's JSON output +identifies the image location. `scripts/boot_release_image.py` boots that +image directly with QEMU and OVMF; it does not build a replacement image or +require the production kernel to include `isa-debug-exit` test code. + +The script requires ordered serial evidence of archive validation, the +`Boot:` volume, numeric PID 1, CPL3 entry and Init's `0x49` syscall marker. +It observes QEMU for another two seconds and rejects early exit, known fatal +or test-only diagnostics, or timeout. It uses snapshot disk writes and +checks that the supplied image's SHA-256 remains unchanged. This is a boot +smoke check, not proof of long-term process health or hardware compatibility. + +Both RC and stable artifact packaging repeat this check on the **exact UEFI +image in the publication directory**, before hashing, upload and attestation. +Run it locally with QEMU and an installed OVMF firmware pair: + +```bash +python3 -I scripts/boot_release_image.py PATH/TO/pvkernel-uefi.img \ + --ovmf-code /usr/share/OVMF/OVMF_CODE_4M.fd \ + --ovmf-vars /usr/share/OVMF/OVMF_VARS_4M.fd +``` diff --git a/docs/security/github-configuration.md b/docs/security/github-configuration.md index b8839c4..cd968c8 100644 --- a/docs/security/github-configuration.md +++ b/docs/security/github-configuration.md @@ -78,6 +78,12 @@ Workflow lint (actionlint) Security policy files ``` +The 0.0.10 correctness gate additionally reports `Host filesystem and release +tooling tests`, `QEMU / release image boot`, and all 12 +`QEMU / process regression ()` matrix checks. Register these as +required after they first report. The process feature names are listed in +`.github/workflows/ci.yml`'s `qemu-process` matrix; every one must pass. + `Security policy files` is reported by `.github/workflows/security-triage.yml`, which deliberately has no `paths:` filter so it runs on every pull request (see [security-triage.md](security-triage.md#policy-file-validation)). diff --git a/docs/security/release-policy.md b/docs/security/release-policy.md index 3ff0c16..858494b 100644 --- a/docs/security/release-policy.md +++ b/docs/security/release-policy.md @@ -38,7 +38,14 @@ Requirements, enforced by the workflow itself: passes; * the full security gate (`security.yml`, reused via `workflow_call`) passes; -* every deterministic QEMU test passes (as part of the CI gate above). +* every deterministic QEMU test passes (as part of the CI gate above), + including all 34 current scenarios; the host gate checks feature coverage + so adding a standalone test feature without a QEMU job fails CI; +* architecture-neutral filesystem tests and Python release-tooling tests pass; +* the production release-profile UEFI image boots to the real PID 1 CPL3 + marker. Artifact packaging repeats this smoke check on the exact image + being published, before checksums and attestation (see + [testing.md](../development/testing.md#production-release-image-boot)). Produces a GitHub **Pre-release**, explicitly marked as such, with: @@ -70,6 +77,8 @@ Example: `v0.0.7`, cut by `.github/workflows/release.yml` Requirements, enforced by the workflow itself, on top of everything an RC requires: +* `CHANGELOG.md` has an exact dated `## [X.Y.Z] - YYYY-MM-DD` entry; + an `Unreleased` entry is accepted for an RC but rejected for a stable release; * the operator explicitly confirms the exact commit SHA being released, and the workflow verifies it matches the ref actually checked out (catches "dispatched against the wrong branch" operator error); @@ -129,3 +138,22 @@ Security release notes clearly state: Detailed exploit instructions are not published prematurely during coordinated disclosure, consistent with [SECURITY.md](../../SECURITY.md)'s coordinated-disclosure section. + +## Preparing pvKernel 0.0.10 after RC2 + +The RC2 source audit recorded 34 local QEMU passes, but its GitHub release +run executed only 22 scenarios. The release-validation correction restores +all 12 process/ELF/isolation scenarios to the build and runtime matrices, +adds host filesystem tests, enforces coverage, and boots production images. +The historical audit remains an account of the original reviewed tree. + +After these changes merge, cut RC3 against the exact updated `main` SHA and +review its full correctness/security gates and packaged-image boot log. +Keep 0.0.10 marked Unreleased until preparing the final stable release; +then add its actual release date and update the roadmap status in a final +documentation change. Stable publishing revalidates that final exact commit. + +Before dispatching the stable workflow, verify Settings -> Environments -> +`release` has at least one required reviewer. The workflow's `environment` +key alone does not enforce this configuration. No repository-settings +verification is implied by a successful RC build or this source change. diff --git a/scripts/boot_release_image.py b/scripts/boot_release_image.py new file mode 100644 index 0000000..d9458ff --- /dev/null +++ b/scripts/boot_release_image.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Smoke-test an actual production UEFI image, without rebuilding or test features. + +Production Init remains alive instead of writing isa-debug-exit. Success +requires the ordered PID 1/CPL3 serial markers, followed by a short observation +window with QEMU still running and no fatal diagnostic. A timeout alone never +passes. Disk writes are redirected to QEMU's temporary snapshot. +""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import subprocess +import tempfile +import time + + +MARKERS = ( + "[initrd] archive validated", + "[vfs] boot volume ready", + "[process] init created with PID 1", + "[init] entered CPL3", + "[syscall] debug byte from CPL3: 0x49", +) +FATAL = ( + "pvKernel PANIC", "[boot] fatal", "[heap] allocation failed", + "[exception] page fault", "[exception] general protection fault", + "[exception] invalid opcode", "[exception] double fault", + "[exception] device not available", "[syscall] BUG:", + "[initrd-test]", # A test-enabled image must not pass this production check. +) + + +def reached_init(log: str) -> bool: + offset = 0 + for marker in MARKERS: + at = log.find(marker, offset) + if at < 0: + return False + offset = at + len(marker) + return True + + +def observe(command: list[str], serial: Path, *, timeout: float, settle: float) -> None: + """Observe a subprocess; always stop it and reject early exit or fatal logs.""" + with tempfile.TemporaryFile() as diagnostics: + process = subprocess.Popen(command, stdout=diagnostics, stderr=diagnostics) + deadline = time.monotonic() + timeout + ready_since = None + try: + while True: + log = serial.read_text(errors="replace") if serial.exists() else "" + if any(marker in log for marker in FATAL): + raise RuntimeError("fatal or test-only diagnostic during production boot") + if process.poll() is not None: + raise RuntimeError(f"QEMU exited before observation completed ({process.returncode})") + now = time.monotonic() + if now >= deadline: + raise RuntimeError("production boot timed out before verification completed") + if reached_init(log): + if ready_since is None: + ready_since = now + if now - ready_since >= settle: + return + time.sleep(0.05) + except BaseException: + if serial.exists(): + print(serial.read_text(errors="replace"), flush=True) + diagnostics.seek(0) + print(diagnostics.read().decode(errors="replace"), flush=True) + raise + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("image", type=Path) + parser.add_argument("--ovmf-code", required=True, type=Path) + parser.add_argument("--ovmf-vars", required=True, type=Path) + parser.add_argument("--qemu", default="qemu-system-x86_64") + parser.add_argument("--timeout", type=float, default=120) + args = parser.parse_args() + if args.timeout <= 2: + parser.error("timeout must exceed the two-second observation window") + image = args.image.resolve(strict=True) + code = args.ovmf_code.resolve(strict=True) + variables = args.ovmf_vars.resolve(strict=True) + before = hashlib.sha256(image.read_bytes()).digest() + try: + with tempfile.TemporaryDirectory(prefix="pvkernel-boot-") as directory: + serial = Path(directory) / "serial.log" + command = [ + args.qemu, "-machine", "q35", "-m", "512M", "-cpu", "max", + "-display", "none", "-monitor", "none", "-nic", "none", "-no-reboot", + "-serial", f"file:{serial}", + "-drive", f"format=raw,file={image},snapshot=on", + "-drive", f"if=pflash,format=raw,unit=0,file={code},readonly=on", + "-drive", f"if=pflash,format=raw,unit=1,file={variables},snapshot=on", + ] + observe(command, serial, timeout=args.timeout, settle=2) + print(serial.read_text(errors="replace"), flush=True) + finally: + if hashlib.sha256(image.read_bytes()).digest() != before: + raise RuntimeError("production image was modified during boot verification") + print("Production release image boot: PASS (PID 1 entered CPL3; image unchanged)") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_qemu_coverage.py b/scripts/check_qemu_coverage.py new file mode 100644 index 0000000..d2f24cc --- /dev/null +++ b/scripts/check_qemu_coverage.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Fail if a deterministic Cargo scenario is absent from the CI QEMU matrix.""" + +from pathlib import Path +import sys +import tomllib + +import yaml + + +def check(root: Path) -> tuple[int, set[str]]: + features = tomllib.loads((root / "Cargo.toml").read_text())["features"] + expected = { + name for name in features if name.endswith(("-test", "-tests")) + } - {"userspace-test"} # Shared scaffolding, not a standalone scenario. + workflow = yaml.safe_load((root / ".github/workflows/ci.yml").read_text()) + covered: set[str] = set() + count = 0 + for job in workflow["jobs"].values(): + for step in job.get("steps", []): + if step.get("uses") != "./.github/actions/qemu-scenario": + continue + selected = step["with"]["features"] + if selected == "${{ matrix.features }}": + matrix = job["strategy"]["matrix"] + selections = matrix.get("features") + if selections is None: + selections = [row["features"] for row in matrix["include"]] + elif "${{" in selected: + raise ValueError("unsupported dynamic QEMU feature selection") + else: + selections = [selected] + for selection in selections: + covered.update(selection.split()) + count += 1 + return count, expected - covered + + +if __name__ == "__main__": + count, missing = check(Path(__file__).resolve().parents[1]) + if missing: + print("Missing deterministic QEMU scenarios: " + ", ".join(sorted(missing))) + sys.exit(1) + print(f"Deterministic QEMU coverage: PASS ({count} scenarios)") diff --git a/scripts/tests/test_boot_release_image.py b/scripts/tests/test_boot_release_image.py new file mode 100644 index 0000000..a13dba2 --- /dev/null +++ b/scripts/tests/test_boot_release_image.py @@ -0,0 +1,58 @@ +"""Exercise success and failure of production-boot observation with real children.""" + +import importlib.util +import contextlib +import io +from pathlib import Path +import sys +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("boot_release_image", REPO / "scripts/boot_release_image.py") +boot = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boot) + + +class ProductionBootTests(unittest.TestCase): + def run_child(self, log, *, after="", exit_early=False, timeout=1.5): + with tempfile.TemporaryDirectory() as directory: + serial = Path(directory) / "serial.log" + body = ( + "import pathlib,sys,time\n" + "p=pathlib.Path(sys.argv[1])\n" + f"p.write_text({log!r})\n" + ) + if after: + body += f"time.sleep(0.05)\nwith p.open('a') as f: f.write({after!r})\n" + if not exit_early: + body += "time.sleep(30)\n" + with contextlib.redirect_stdout(io.StringIO()): + boot.observe([sys.executable, "-c", body, str(serial)], serial, timeout=timeout, settle=0.2) + + def test_normal_pid1_boot_remains_running(self): + self.run_child("\n".join(boot.MARKERS)) + + def test_timeout_without_cpl3_marker_fails(self): + with self.assertRaisesRegex(RuntimeError, "timed out"): + self.run_child("\n".join(boot.MARKERS[:-2]), timeout=0.35) + + def test_early_exit_is_not_a_success_even_with_markers(self): + with self.assertRaisesRegex(RuntimeError, "exited"): + self.run_child("\n".join(boot.MARKERS), exit_early=True) + + def test_panic_after_marker_fails_during_observation(self): + with self.assertRaisesRegex(RuntimeError, "fatal"): + self.run_child("\n".join(boot.MARKERS), after="\npvKernel PANIC\n") + + def test_user_fault_after_marker_fails(self): + with self.assertRaisesRegex(RuntimeError, "fatal"): + self.run_child("\n".join(boot.MARKERS), after="\n[exception] page fault\n") + + def test_test_enabled_image_is_rejected(self): + with self.assertRaisesRegex(RuntimeError, "test-only"): + self.run_child("\n".join(boot.MARKERS) + "\n[initrd-test] PID 1 CPL3 marker accepted") + + def test_wrong_pid_or_out_of_order_markers_do_not_pass(self): + self.assertFalse(boot.reached_init("\n".join(boot.MARKERS).replace("PID 1", "PID 2"))) + self.assertFalse(boot.reached_init("\n".join(reversed(boot.MARKERS)))) diff --git a/scripts/tests/test_check_qemu_coverage.py b/scripts/tests/test_check_qemu_coverage.py new file mode 100644 index 0000000..740f2df --- /dev/null +++ b/scripts/tests/test_check_qemu_coverage.py @@ -0,0 +1,38 @@ +"""Prevent regression to a green CI run that silently omits a Cargo scenario.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("check_qemu_coverage", REPO / "scripts/check_qemu_coverage.py") +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) + + +class CoverageTests(unittest.TestCase): + def test_current_matrix_covers_all_34_scenarios(self): + self.assertEqual(coverage.check(REPO), (34, set())) + + def test_removing_process_runtime_job_is_detected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / ".github/workflows").mkdir(parents=True) + (root / "Cargo.toml").write_text((REPO / "Cargo.toml").read_text()) + ci = (REPO / ".github/workflows/ci.yml").read_text() + (root / ".github/workflows/ci.yml").write_text(ci.split("\n qemu-process:")[0]) + count, missing = coverage.check(root) + self.assertEqual(count, 22) + self.assertEqual(len(missing), 12) + self.assertIn("address-space-isolation-test", missing) + self.assertIn("elf-malformed-test", missing) + + def test_new_cargo_scenario_requires_runtime_coverage(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / ".github/workflows").mkdir(parents=True) + cargo = (REPO / "Cargo.toml").read_text().replace("[features]\n", "[features]\nnew-regression-test = []\n") + (root / "Cargo.toml").write_text(cargo) + (root / ".github/workflows/ci.yml").write_text((REPO / ".github/workflows/ci.yml").read_text()) + self.assertEqual(coverage.check(root)[1], {"new-regression-test"})