Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/actions/build-release-artifacts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions .github/actions/release-boot-smoke/action.yml
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 36 additions & 4 deletions docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
6 changes: 6 additions & 0 deletions docs/security/github-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<feature>)` 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)).
Expand Down
30 changes: 29 additions & 1 deletion docs/security/release-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
118 changes: 118 additions & 0 deletions scripts/boot_release_image.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading