boot2

Playing with the boostrap
git clone https://git.ryansepassi.com/git/boot2.git
Log | Files | Refs | README

commit 4732be263077c6e3cf4b956bd9e963948c5ef797
parent 3ec247c67e856e6f3fd25889b2785a1d545b1aaf
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 17 Jul 2026 17:39:26 -0700

release: verify with two bootstrap runs

Diffstat:
MMakefile | 10+++++-----
MREADME.md | 12+++++++-----
Mtools/mkrelease.sh | 52++--------------------------------------------------
Mtools/release.sh | 147+++++++++++++++++++++++++++++++++++++++----------------------------------------
Atools/release/input-manifest.sh | 43+++++++++++++++++++++++++++++++++++++++++++
Atools/release/tar-payload.sh | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 182 insertions(+), 135 deletions(-)

diff --git a/Makefile b/Makefile @@ -74,7 +74,7 @@ help: @echo ' make all build boot6 kernel' @echo ' make src prep canonical src/ tree (incl. musl)' @echo ' make package quick: package boot2-<arch>.tar.gz from current build' - @echo ' make release validated: clean-rebuild x2 + verify, mint to dist/' + @echo ' make release validated: build + input/tar/output proofs, mint to dist/' @echo ' make build/<arch>/<driver>/boot6/<kn> full chain (kn = Image | kernel.elf)' @echo ' make build/<arch>/<driver>/bootN/<file> any single artifact' @echo ' make test SUITE=<suite> test suite (NAMES=<filter> optional)' @@ -95,10 +95,10 @@ package: build/$(ARCH)/$(DRIVER)/boot5/libc.a \ build/$(ARCH)/$(DRIVER)/boot6/$(KERNEL_NAME_$(ARCH)) DRIVER=$(DRIVER) tools/mkrelease.sh $(ARCH) -# `release`: the validated path. Cleans, rebuilds + packages twice, -# asserts both tarballs hash-match, extracts one and runs its -# verify.sh, then promotes to dist/. Slow but paranoid; this is the -# only way a tarball lands in dist/. +# `release`: the validated path. Builds + packages once, regenerates +# and compares canonical inputs, repackages the sealed payload to prove +# stable tar bytes, then rebuilds from the archive and compares every +# output hash. This is the only way a tarball lands in dist/. release: DRIVER=$(DRIVER) tools/release.sh $(ARCH) diff --git a/README.md b/README.md @@ -187,10 +187,11 @@ canonical generated source tree (used by every stage) is at ## Reproducible releases -The validated release path performs two clean builds, asserts that their -tarballs are byte-identical, extracts one into a fresh directory, reruns -boot0 through boot6 from the bundled inputs, and compares every key output -against its SHA-256 manifest: +The validated release path performs one clean build, regenerates the +canonical source tree and compares its input manifest, repackages the sealed +payload and asserts byte-identical tar output, then rebuilds boot0 through +boot6 from the extracted archive and compares every key output against its +SHA-256 manifest: ```sh ARCH=aarch64 # or amd64, riscv64 @@ -198,7 +199,8 @@ make release ARCH="$ARCH" DRIVER=podman ``` **Warning:** `make release` deliberately removes the entire `build/` tree -before each of its two passes. The publishable results are: +before the build and again before the canonical-input check. The publishable +results are: * `dist/boot2-$ARCH.tar.gz` — self-contained source and verification bundle * `dist/boot2-$ARCH.tar.gz.sha256` — archive digest diff --git a/tools/mkrelease.sh b/tools/mkrelease.sh @@ -103,13 +103,7 @@ done # ── (4) INPUT_MANIFEST.txt ──────────────────────────────────────────── echo "[mkrelease] input manifest" INMAN=$STAGING/INPUT_MANIFEST.txt -( - cd "$STAGING" - find src boot -type f | LC_ALL=C sort | while read -r rel; do - h=$(sha256 "$rel") - printf '%s %s\n' "$h" "$rel" - done -) > "$INMAN" +tools/release/input-manifest.sh "$STAGING/src" "$STAGING/boot" "$INMAN" n_in=$(wc -l < "$INMAN" | tr -d ' ') # ── (5) OUTPUT_MANIFEST.txt ─────────────────────────────────────────── @@ -166,51 +160,9 @@ fi n_out=$(wc -l < "$OUTMAN" | tr -d ' ') # ── (6) tarball — deterministic ────────────────────────────────────── -# Identical inputs must yield byte-identical tarballs. Sources of drift: -# (a) gzip header embeds wall-clock mtime + original filename -# → use `gzip -n` (no name, no timestamp). -# (b) tar entries embed per-file mtime + uid/gid/uname/gname. -# → normalize on-disk mtimes with `touch -t`; override ownership -# in the tar headers via --uid/--gid/--uname/--gname. -# (c) tar entry order = filesystem readdir order. -# → sorted -T file list. -# (d) a -T list containing both directories and their descendants makes -# tar add descendants once per ancestor unless recursion is disabled. -# → --no-recursion; every entry is already present in the find list. -# Note: macOS bsdtar refuses `--mtime` together with `-T -`. We avoid -# that combo by normalizing mtimes on disk first. -# Also: a pipeline like `... | tar | gzip > out` silently produces an -# empty gzip if tar fails. Build the uncompressed tar to a tempfile so -# `set -e` catches tar's exit code, then gzip from disk. echo "[mkrelease] tar -> $TARBALL" -find "$STAGING" -exec touch -t 200001010000.00 {} + - -TAR_TMP=$PWD/$REL_DIR/$NAME.tar TARBALL_ABS=$PWD/$TARBALL -rm -f "$TAR_TMP" "$TARBALL" -( - cd "$REL_DIR" - find "$NAME" -print0 | LC_ALL=C sort -z | \ - tar --null --no-recursion -T - \ - --uid 0 --gid 0 --uname '' --gname '' \ - --format ustar \ - -cf "$TAR_TMP" -) -gzip -n -9 < "$TAR_TMP" > "$TARBALL_ABS" -rm -f "$TAR_TMP" - -# A deterministic archive must also contain each pathname exactly once. -# Keep this as a packaging invariant so a future tar invocation cannot -# silently reintroduce recursive duplicates. -DUPES=$REL_DIR/$NAME.tar.entries.duplicate -tar -tzf "$TARBALL" | LC_ALL=C sort | uniq -d > "$DUPES" -if [ -s "$DUPES" ]; then - echo "[mkrelease] FAIL: duplicate archive entries:" >&2 - sed 's/^/ /' "$DUPES" >&2 - rm -f "$DUPES" - exit 1 -fi -rm -f "$DUPES" +tools/release/tar-payload.sh "$STAGING" "$TARBALL_ABS" # Tarball digest (echoed for release notes; not embedded in the tar). TAR_SHA=$(sha256 "$TARBALL") diff --git a/tools/release.sh b/tools/release.sh @@ -3,27 +3,21 @@ ## ## Releases are critical, so the path to minting one is paranoid: ## -## 1. `make clean` — wipe build/ entirely. -## 2. `make package` — full prep-src → boot0..boot6 → mkrelease.sh chain. -## Capture tarball A's sha256. -## 3. `make clean` again — fresh state, no cached intermediates. -## 4. `make package` again — full rebuild + repackage. -## Capture tarball B's sha256. -## 5. Assert A == B. Bit-for-bit reproducibility check; if this drifts, -## something in the input set or build is nondeterministic and the -## release is not safe to publish. -## 6. Extract one tarball into a fresh dir under $HOME (so the macOS -## podman VM can see it) and run the bundled verify.sh. verify.sh -## re-runs boot0..boot6 off the bundled inputs and diffs each -## artifact's sha256 against OUTPUT_MANIFEST.txt. End-to-end proof -## that the shipped tarball reproduces the claimed outputs. -## 7. Promote the validated tarball to dist/<name>.tar.gz with a +## 1. Clean-build and package prep-src → boot0..boot6 once. +## 2. Extract the archive into a fresh directory under $HOME. +## 3. Clean-regenerate the checkout's canonical src/ tree and compare +## its full boot/ + src/ hash manifest with the bundled +## INPUT_MANIFEST.txt. +## 4. Repackage the extracted sealed payload and assert that its tarball +## is byte-identical to the original. +## 5. Run the bundled verify.sh. It rebuilds boot0..boot6 from only the +## sealed inputs and compares every artifact with OUTPUT_MANIFEST.txt, +## proving both build determinism and archive completeness. +## 6. Promote the validated tarball to dist/<name>.tar.gz with a ## sha256 sidecar. dist/ is the only directory mkrelease.sh / ## release.sh ever writes to that's intended for publication. ## -## On any failure the dist/ output is NOT created; the operator gets a -## clear error pointing at which pass failed. If the two passes differ, -## both tarballs are copied to dist/.failed-passes/ for inspection. +## On any failure no new dist/ output is promoted. ## ## Usage: tools/release.sh <arch> ## <arch> ∈ {aarch64, amd64, riscv64} @@ -62,8 +56,7 @@ NAME=boot2-$ARCH REL_DIR=build/$ARCH/release DIST=dist -# Pass-tarball vault — must live OUTSIDE build/ so it survives the -# `make clean` between passes. mktemp under /tmp; cleaned on exit. +# Validation vault — must live outside build/ so it survives make clean. VAULT=$(mktemp -d -t boot2-release-XXXXXX) trap 'rm -rf "$VAULT"' EXIT @@ -79,78 +72,82 @@ hr() { printf '[release] ===================== %s =====================\n' "$*" t0=$(date +%s) -# Two passes from a clean state. Each pass: make clean + make package. -# Save each tarball aside under a pass-specific name so we can compare -# them even if the recipe is rerun by hand later. -do_pass() { - _label=$1; _outvar=$2 - hr "pass $_label" - log "make clean" - make clean >/dev/null - log "make package ARCH=$ARCH DRIVER=$DRIVER" - make package ARCH="$ARCH" DRIVER="$DRIVER" - _src=$REL_DIR/$NAME.tar.gz - _dst=$VAULT/$NAME.pass-$_label.tar.gz - [ -f "$_src" ] || { log "FAIL: pass $_label produced no $_src"; exit 1; } - cp "$_src" "$_dst" - _h=$(sha256 "$_dst") - log "pass $_label sha256: $_h" - eval "$_outvar=\$_h" -} - -do_pass A SHA_A -do_pass B SHA_B - -hr "compare" -if [ "$SHA_A" != "$SHA_B" ]; then - log "FAIL: pass A and pass B produced DIFFERENT tarballs" - log " A: $SHA_A ($VAULT/$NAME.pass-A.tar.gz)" - log " B: $SHA_B ($VAULT/$NAME.pass-B.tar.gz)" - log "" - log "Copying tarballs to dist/.failed-passes/ for inspection (vault is auto-cleaned)." - mkdir -p "$DIST/.failed-passes" - cp "$VAULT/$NAME.pass-A.tar.gz" "$DIST/.failed-passes/" - cp "$VAULT/$NAME.pass-B.tar.gz" "$DIST/.failed-passes/" - log "To investigate, extract both and diff the trees:" - log " mkdir /tmp/A /tmp/B" - log " tar xzf $DIST/.failed-passes/$NAME.pass-A.tar.gz -C /tmp/A" - log " tar xzf $DIST/.failed-passes/$NAME.pass-B.tar.gz -C /tmp/B" - log " diff -r /tmp/A /tmp/B" - log "(check INPUT_MANIFEST.txt and OUTPUT_MANIFEST.txt for hash drift)" - exit 1 -fi -log "OK: both passes produced identical tarballs" -log " sha256 = $SHA_A" - -# End-to-end verify: extract the tarball, run its verify.sh in a fresh -# tree, and hash-diff every artifact in OUTPUT_MANIFEST.txt. Always run -# podman first: a seed verification needs the podman-built boot6 kernel -# as its runtime. When the release itself was built with DRIVER=seed, -# follow the podman proof with a seed proof against the same manifest. -hr "verify" +hr "build" +log "make clean" +make clean >/dev/null +log "make package ARCH=$ARCH DRIVER=$DRIVER" +make package ARCH="$ARCH" DRIVER="$DRIVER" +ARCHIVE=$VAULT/$NAME.original.tar.gz +[ -f "$REL_DIR/$NAME.tar.gz" ] || { log "FAIL: package produced no archive"; exit 1; } +cp "$REL_DIR/$NAME.tar.gz" "$ARCHIVE" +SHA_A=$(sha256 "$ARCHIVE") +log "archive sha256: $SHA_A" + +# Extract before regenerating checkout inputs. The verify directory must +# live under $HOME because the macOS podman VM only mounts /Users/. +hr "extract" # macOS podman VM only mounts /Users/, so the verify dir must live # under $HOME. ~/.cache is a stable, throwaway-friendly location. VERIFY_BASE=$HOME/.cache/boot2-release-verify/$ARCH log "extract -> $VERIFY_BASE/$NAME" rm -rf "$VERIFY_BASE" mkdir -p "$VERIFY_BASE" -tar xzf "$VAULT/$NAME.pass-A.tar.gz" -C "$VERIFY_BASE" +tar xzf "$ARCHIVE" -C "$VERIFY_BASE" + +SEALED=$VERIFY_BASE/$NAME +INPUT_MANIFEST=$SEALED/INPUT_MANIFEST.txt +OUTPUT_MANIFEST=$SEALED/OUTPUT_MANIFEST.txt +[ -f "$INPUT_MANIFEST" ] || { log "FAIL: archive has no INPUT_MANIFEST.txt"; exit 1; } +[ -f "$OUTPUT_MANIFEST" ] || { log "FAIL: archive has no OUTPUT_MANIFEST.txt"; exit 1; } + +# Independently regenerate canonical inputs from the checkout. This is +# cheap compared with boot3 and proves prep-src produces the sealed tree. +hr "inputs" +log "make clean" +make clean >/dev/null +log "make src ARCH=$ARCH DRIVER=$DRIVER" +make src ARCH="$ARCH" DRIVER="$DRIVER" +REGENERATED_INPUTS=$VAULT/$NAME.inputs.regenerated.txt +tools/release/input-manifest.sh "build/$ARCH/src" boot "$REGENERATED_INPUTS" +if ! cmp -s "$INPUT_MANIFEST" "$REGENERATED_INPUTS"; then + log "FAIL: regenerated canonical inputs differ from INPUT_MANIFEST.txt" + diff -u "$INPUT_MANIFEST" "$REGENERATED_INPUTS" || true + exit 1 +fi +log "OK: regenerated canonical inputs match ($(wc -l < "$INPUT_MANIFEST" | tr -d ' ') files)" + +# Repackage the extracted payload with the same deterministic archiver. +# This proves the published tar bytes are stable without rebuilding boot3. +hr "repackage" +REPACKED=$VAULT/$NAME.repacked.tar.gz +tools/release/tar-payload.sh "$SEALED" "$REPACKED" +SHA_REPACKED=$(sha256 "$REPACKED") +if [ "$SHA_A" != "$SHA_REPACKED" ]; then + log "FAIL: repackaged payload differs from original archive" + log " original : $SHA_A" + log " repacked : $SHA_REPACKED" + exit 1 +fi +log "OK: repackaged payload is byte-identical" +log " sha256 = $SHA_A" + +# End-to-end build from the archive. Always run podman first: a seed +# verification needs the podman-built boot6 kernel as its runtime. +hr "verify" log "running ./verify.sh (DRIVER=podman) — this rebuilds the chain" -( cd "$VERIFY_BASE/$NAME" && DRIVER=podman ./verify.sh ) +( cd "$SEALED" && DRIVER=podman ./verify.sh ) if [ "$DRIVER" = seed ]; then log "running ./verify.sh (DRIVER=seed) — this closes the kernel loop" - ( cd "$VERIFY_BASE/$NAME" && DRIVER=seed ./verify.sh ) + ( cd "$SEALED" && DRIVER=seed ./verify.sh ) fi -OUTPUT_MANIFEST=$VERIFY_BASE/$NAME/OUTPUT_MANIFEST.txt -[ -f "$OUTPUT_MANIFEST" ] || { log "FAIL: archive has no OUTPUT_MANIFEST.txt"; exit 1; } OUTPUT_MANIFEST_SHA=$(sha256 "$OUTPUT_MANIFEST") # Promote to dist/ — the only directory we treat as "publishable". hr "mint" mkdir -p "$DIST" -cp "$VAULT/$NAME.pass-A.tar.gz" "$DIST/$NAME.tar.gz" +cp "$ARCHIVE" "$DIST/$NAME.tar.gz" printf '%s %s\n' "$SHA_A" "$NAME.tar.gz" > "$DIST/$NAME.tar.gz.sha256" cp "$OUTPUT_MANIFEST" "$DIST/$NAME.outputs.sha256" diff --git a/tools/release/input-manifest.sh b/tools/release/input-manifest.sh @@ -0,0 +1,43 @@ +#!/bin/sh +## input-manifest.sh — hash a sealed boot2 src/ + boot/ input pair. +## +## Usage: tools/release/input-manifest.sh <src-dir> <boot-dir> <output> +## +## Paths in the output are rooted at src/ and boot/ regardless of where +## the two input directories live. This lets release.sh compare a freshly +## regenerated checkout tree with the manifest bundled in a release. + +set -eu + +SRC_DIR=${1:-} +BOOT_DIR=${2:-} +OUTPUT=${3:-} +[ -d "$SRC_DIR" ] || { echo "input-manifest: missing src dir: $SRC_DIR" >&2; exit 2; } +[ -d "$BOOT_DIR" ] || { echo "input-manifest: missing boot dir: $BOOT_DIR" >&2; exit 2; } +[ -n "$OUTPUT" ] || { echo "usage: $0 <src-dir> <boot-dir> <output>" >&2; exit 2; } + +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1" | awk '{print $1}'; } +else + sha256() { shasum -a 256 "$1" | awk '{print $1}'; } +fi + +emit_tree() { + _prefix=$1 + _dir=$2 + ( + cd "$_dir" + find . -type f | LC_ALL=C sort | while read -r _path; do + _rel=${_path#./} + _hash=$(sha256 "$_path") + printf '%s %s/%s\n' "$_hash" "$_prefix" "$_rel" + done + ) +} + +# LC_ALL=C lexical order puts boot/ before src/, matching the bundled +# INPUT_MANIFEST.txt contract. +{ + emit_tree boot "$BOOT_DIR" + emit_tree src "$SRC_DIR" +} > "$OUTPUT" diff --git a/tools/release/tar-payload.sh b/tools/release/tar-payload.sh @@ -0,0 +1,53 @@ +#!/bin/sh +## tar-payload.sh — create a deterministic boot2 release archive. +## +## Usage: tools/release/tar-payload.sh <payload-dir> <output.tar.gz> + +set -eu + +PAYLOAD_ARG=${1:-} +OUTPUT_ARG=${2:-} +[ -d "$PAYLOAD_ARG" ] || { echo "tar-payload: missing payload dir: $PAYLOAD_ARG" >&2; exit 2; } +[ -n "$OUTPUT_ARG" ] || { echo "usage: $0 <payload-dir> <output.tar.gz>" >&2; exit 2; } + +PAYLOAD_PARENT=$(cd "$(dirname "$PAYLOAD_ARG")" && pwd) +PAYLOAD_NAME=$(basename "$PAYLOAD_ARG") +PAYLOAD=$PAYLOAD_PARENT/$PAYLOAD_NAME +OUTPUT_PARENT=$(cd "$(dirname "$OUTPUT_ARG")" && pwd) +OUTPUT=$OUTPUT_PARENT/$(basename "$OUTPUT_ARG") + +case "$OUTPUT" in + "$PAYLOAD"/*) + echo "tar-payload: output must not be inside payload: $OUTPUT" >&2 + exit 2 + ;; +esac + +TAR_TMP=$OUTPUT_PARENT/.$(basename "$OUTPUT").tmp.tar +GZIP_TMP=$OUTPUT_PARENT/.$(basename "$OUTPUT").tmp.gz +DUPES=$OUTPUT_PARENT/.$(basename "$OUTPUT").entries.duplicate +cleanup() { rm -f "$TAR_TMP" "$GZIP_TMP" "$DUPES"; } +trap cleanup 0 1 2 15 + +# Normalize all archive metadata and ordering. The input list already +# contains every directory and file, so tar recursion must stay disabled. +find "$PAYLOAD" -exec touch -t 200001010000.00 {} + +( + cd "$PAYLOAD_PARENT" + find "$PAYLOAD_NAME" -print0 | LC_ALL=C sort -z | \ + tar --null --no-recursion -T - \ + --uid 0 --gid 0 --uname '' --gname '' \ + --format ustar \ + -cf "$TAR_TMP" +) +gzip -n -9 < "$TAR_TMP" > "$GZIP_TMP" +mv "$GZIP_TMP" "$OUTPUT" + +# Reject recursive duplicates even if the tar command is changed later. +tar -tzf "$OUTPUT" | LC_ALL=C sort | uniq -d > "$DUPES" +if [ -s "$DUPES" ]; then + echo "tar-payload: duplicate archive entries:" >&2 + sed 's/^/ /' "$DUPES" >&2 + rm -f "$OUTPUT" + exit 1 +fi