diff --git a/README.md b/README.md index 70cc2c6..1ec23ea 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarc See the ISO link on [omarchy.org](https://omarchy.org). +Every published ISO has a `.sha256` beside it at the same URL. Download both into the same directory and check the ISO before writing it to a USB stick: + +```bash +sha256sum -c omarchy-3.0.iso.sha256 +``` + +Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. There is a `.sig` beside the ISO too for anyone who wants to verify it against the Omarchy signing key. + ## Creating the ISO Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror. @@ -130,7 +138,7 @@ Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved ## Uploading the ISO -Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). +Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). The `.sig` and `.sha256` sidecars go up with the ISO when they exist beside it. ## Full release of the ISO diff --git a/bin/omarchy-iso-release b/bin/omarchy-iso-release index cf5eb99..b0423fa 100755 --- a/bin/omarchy-iso-release +++ b/bin/omarchy-iso-release @@ -56,7 +56,17 @@ cp "$latest_iso" "$release_iso" echo -e "\e[32mSigning $ISO_REF ISO\e[0m" omarchy-iso-sign "$release_iso" +# The checksum sidecar names the ISO alone, never the build path: it is +# downloaded next to the ISO and has to verify with `sha256sum -c` from +# whatever directory the person who downloaded it happens to be in. A release +# that cannot write it stops here, because the sidecar left over from the last +# release reads as a checksum for this one. iso_sha=$(sha256sum "$release_iso") +iso_sha=${iso_sha%% *} +if [[ -z $iso_sha ]] || ! printf '%s %s\n' "$iso_sha" "${release_iso##*/}" >"$release_iso.sha256"; then + echo -e "\e[31mCould not write the checksum for $release_iso\e[0m" >&2 + exit 1 +fi echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m" omarchy-iso-upload "$release_iso" diff --git a/bin/omarchy-iso-upload b/bin/omarchy-iso-upload index a154858..3c55ec0 100755 --- a/bin/omarchy-iso-upload +++ b/bin/omarchy-iso-upload @@ -10,8 +10,24 @@ if [[ ! -f ~/.config/rclone/rclone.conf ]]; then exit 1 fi -rclone copy $1 Omarchy:omarchy/ -P +# Every copy reports, and one failure fails the upload. Without the tally the +# last copy decides the exit status, so a signature that never made it is +# hidden by a checksum that did. +status=0 -if [[ -f $1.sig ]]; then - rclone copy $1.sig Omarchy:omarchy/ -P +rclone copy "$1" Omarchy:omarchy/ -P || status=1 + +# The signature and the checksum are only worth anything to someone who can +# reach them beside the ISO, so they go up in the same run rather than a step +# a release can finish without. +for sidecar in "$1.sig" "$1.sha256"; do + if [[ -f $sidecar ]]; then + rclone copy "$sidecar" Omarchy:omarchy/ -P || status=1 + fi +done + +if [[ ! -f $1.sha256 ]]; then + echo "Warning: no $1.sha256 to publish, so nobody can verify this download" >&2 fi + +exit $status diff --git a/test/unit/iso-release-checksum-test.sh b/test/unit/iso-release-checksum-test.sh new file mode 100755 index 0000000..56e50f1 --- /dev/null +++ b/test/unit/iso-release-checksum-test.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# +# Unit tests for the checksum sidecar omarchy-iso-release writes and +# omarchy-iso-upload ships. Both scripts derive everything from their own +# location and their argument, so each case runs against a throwaway sandbox +# with the real script copied in and rclone/sign/upload stubbed out. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + local description="$1" + local detail="${2:-}" + + [[ -n $detail ]] && printf '%s\n' "$detail" >&2 + printf 'not ok - %s\n' "$description" >&2 + exit 1 +} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# omarchy-iso-release resolves its release directory from the script's own +# path, so relocating the script is what points it at the sandbox. +sandbox="$work/repo" +mkdir -p "$sandbox/bin" "$sandbox/release" "$work/stubs" +cp "$ROOT/bin/omarchy-iso-release" "$sandbox/bin/" +printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso" + +cat >"$work/stubs/omarchy-iso-sign" <<'STUB' +#!/bin/bash +printf 'signature\n' >"$1.sig" +STUB + +cat >"$work/stubs/omarchy-iso-upload" <<'STUB' +#!/bin/bash +printf 'upload %s\n' "$*" >>"$TEST_LOG" +STUB + +# Log one argument per field rather than "$*": the point of several cases below +# is that a path survives as a single argument, which "$*" cannot show. +cat >"$work/stubs/rclone" <<'STUB' +#!/bin/bash +printf 'rclone' +printf '|%s' "$@" +printf '\n' +[[ -n ${RCLONE_FAIL_ON:-} && $* == *"$RCLONE_FAIL_ON"* ]] && exit 1 +exit 0 +STUB + +chmod +x "$work/stubs"/* + +export TEST_LOG="$work/log" +: >"$TEST_LOG" + +run_upload() { + PATH="$work/stubs:$PATH" "$ROOT/bin/omarchy-iso-upload" "$@" >"$work/rclone-log" 2>"$work/upload-err" +} + +PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 9.9.9 >/dev/null + +release_iso="$sandbox/release/omarchy-9.9.9.iso" +checksum_file="$release_iso.sha256" + +[[ -f $checksum_file ]] || + fail "release writes a checksum beside the ISO" "missing: $checksum_file" +pass "release writes a checksum beside the ISO" + +# The name in the file has to be the ISO alone. A build path in there verifies +# only on the release machine, which is the one machine that never needs it. +expected="$(sha256sum "$release_iso" | cut -d " " -f 1) omarchy-9.9.9.iso" +actual="$(cat "$checksum_file")" +[[ $actual == "$expected" ]] || + fail "the checksum names the ISO alone" "expected: $expected"$'\n'"actual: $actual" +pass "the checksum names the ISO alone" + +(cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256) || + fail "sha256sum -c verifies the ISO from its own directory" +pass "sha256sum -c verifies the ISO from its own directory" + +# The whole point of the sidecar is catching bytes that changed after release, +# so prove it fails on bytes that changed after release. +printf 'corrupted\n' >>"$release_iso" +if (cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256 2>/dev/null); then + fail "sha256sum -c rejects a corrupted ISO" +fi +pass "sha256sum -c rejects a corrupted ISO" + +# An unreadable ISO must stop the release rather than publish an empty digest +# beside a stale sidecar from the previous one. +stale="$sandbox/release/omarchy-8.8.8.iso.sha256" +printf 'deadbeef omarchy-8.8.8.iso\n' >"$stale" +rm "$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso" +printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso" +cp "$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso" "$sandbox/release/omarchy-8.8.8.iso" +chmod 000 "$sandbox/release/omarchy-8.8.8.iso" + +set +e +PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 8.8.8 >/dev/null 2>&1 +release_status=$? +set -e +chmod 644 "$sandbox/release/omarchy-8.8.8.iso" + +(( release_status != 0 )) || + fail "release stops when the ISO cannot be checksummed" "exit status was 0" +[[ "$(cat "$stale")" == "deadbeef omarchy-8.8.8.iso" ]] || + fail "a failed checksum leaves no half-written sidecar" "$(cat "$stale")" +pass "release stops when the ISO cannot be checksummed" + +# Uploading: both sidecars go up with the ISO, and a missing one is skipped +# rather than handed to rclone as a path that does not exist. +export HOME="$work/home" +mkdir -p "$HOME/.config/rclone" +: >"$HOME/.config/rclone/rclone.conf" + +# A space in the path is the case that tells a quoted argument from an unquoted +# one, so every upload case runs from a directory that has one. +mkdir -p "$work/release builds" +upload_iso="$work/release builds/omarchy-9.9.9.iso" +printf 'not really an iso\n' >"$upload_iso" +printf 'signature\n' >"$upload_iso.sig" +printf 'checksum\n' >"$upload_iso.sha256" + +run_upload "$upload_iso" + +for suffix in "" ".sig" ".sha256"; do + grep -qxF "rclone|copy|$upload_iso$suffix|Omarchy:omarchy/|-P" "$work/rclone-log" || + fail "upload ships the ISO, its signature and its checksum, path spaces intact" \ + "$(cat "$work/rclone-log")" +done +pass "upload ships the ISO, its signature and its checksum, path spaces intact" + +rm "$upload_iso.sig" +run_upload "$upload_iso" + +if grep -qF ".sig" "$work/rclone-log"; then + fail "upload skips a sidecar that is not there" "$(cat "$work/rclone-log")" +fi +grep -qF "$upload_iso.sha256" "$work/rclone-log" || + fail "upload still ships the checksum without a signature" "$(cat "$work/rclone-log")" +pass "upload skips a sidecar that is not there" + +# A failed copy has to fail the upload even when a later copy succeeds, or a +# release reports success having published an ISO nobody can download. +printf 'signature\n' >"$upload_iso.sig" +set +e +RCLONE_FAIL_ON=".sig" run_upload "$upload_iso" +upload_status=$? +set -e +(( upload_status != 0 )) || + fail "a failed sidecar copy fails the upload" "$(cat "$work/rclone-log")" +grep -qF "$upload_iso.sha256" "$work/rclone-log" || + fail "a failed sidecar copy still attempts the rest" "$(cat "$work/rclone-log")" +pass "a failed sidecar copy fails the upload without skipping the rest" + +set +e +RCLONE_FAIL_ON="omarchy-9.9.9.iso" run_upload "$upload_iso" +upload_status=$? +set -e +(( upload_status != 0 )) || + fail "a failed ISO copy fails the upload" "$(cat "$work/rclone-log")" +pass "a failed ISO copy fails the upload" + +# Publishing an ISO nobody can verify is the thing this change exists to stop, +# so the uploader says so out loud rather than exiting quietly. +rm "$upload_iso.sha256" +run_upload "$upload_iso" +grep -qF "nobody can verify" "$work/upload-err" || + fail "upload warns when there is no checksum to publish" "$(cat "$work/upload-err")" +pass "upload warns when there is no checksum to publish"