diff --git a/bin/omarchy-iso-test-windows-disk b/bin/omarchy-iso-test-windows-disk index d245f33..3e0e70a 100755 --- a/bin/omarchy-iso-test-windows-disk +++ b/bin/omarchy-iso-test-windows-disk @@ -5,11 +5,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(dirname "$SCRIPT_DIR")" -TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}" -TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}" DISK_SIZE_GIB=96 MIN_FREE_GIB=64 RECREATE=0 +GAP=0 ISO="" ACTIVE_NBD="" @@ -19,7 +18,7 @@ TEMPORARY_DISK="" usage() { cat < 0 )); do RECREATE=1 shift ;; + --gap) + GAP=1 + shift + ;; -h|--help) usage exit 0 @@ -66,6 +77,18 @@ while (( $# > 0 )); do esac done +# Separate images per layout so the two fixtures can coexist and neither has +# to be rebuilt to run the other. +if (( GAP )); then + TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows-gap.qcow2}" + TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows-gap.fd}" + LAYOUT_LABEL="512MiB Windows ESP + data + deleted slot 3 + data + 64GiB+ free" +else + TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}" + TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}" + LAYOUT_LABEL="512MiB Windows ESP + existing data + 64GiB+ free" +fi + cleanup_mount() { if (( MOUNTED )); then sudo umount "$MOUNT_DIR" >/dev/null 2>&1 || true @@ -149,7 +172,7 @@ largest_free_region() { } validate_test_disk() { - local part_count pttype free_space_b min_free_b + local part_count pttype free_space_b min_free_b want_parts numbers local esp data valid=1 [[ -f $TEST_DISK ]] || return 1 @@ -162,9 +185,17 @@ validate_test_disk() { pttype=$(lsblk -dnro PTTYPE "$ACTIVE_NBD" 2>/dev/null || true) free_space_b=$(largest_free_region "$ACTIVE_NBD") min_free_b=$((MIN_FREE_GIB * 1024 * 1024 * 1024)) + want_parts=2 + (( GAP )) && want_parts=3 [[ $pttype == gpt ]] || valid=0 - (( part_count == 2 )) || valid=0 + (( part_count == want_parts )) || valid=0 + + # The whole point of the gap fixture: slot 3 free, slot 4 in use. + if (( GAP )); then + numbers=$(sudo parted -ms "$ACTIVE_NBD" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1 | sort | tr '\n' ' ') + [[ $numbers == "1 2 4 " ]] || valid=0 + fi (( free_space_b >= min_free_b )) || valid=0 [[ $(sudo blkid -s TYPE -o value "$esp" 2>/dev/null || true) == vfat ]] || valid=0 [[ $(sudo blkid -s LABEL -o value "$esp" 2>/dev/null || true) == WINDOWS_ESP ]] || valid=0 @@ -206,6 +237,18 @@ create_test_disk() { sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_ESP fat32 1MiB 513MiB sudo parted --script "$ACTIVE_NBD" set 1 esp on sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_DATA ext4 513MiB 20GiB + + if (( GAP )); then + # Slot 3 is created and deleted, slot 4 stays. parted then fills 3 before + # 5, so an installer that assumes "highest + 1" (which would be 5 and 6) + # writes to device nodes that do not exist. The deleted region is + # deliberately smaller than the trailing free space so the installer still + # picks the tail as its largest free region. + sudo parted --script "$ACTIVE_NBD" mkpart SCRATCH ext4 20GiB 28GiB + sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_MORE ext4 28GiB 30GiB + sudo parted --script "$ACTIVE_NBD" rm 3 + fi + sudo partprobe "$ACTIVE_NBD" sudo udevadm settle @@ -281,7 +324,7 @@ cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$TEST_OVMF_VARS" gum style --border rounded --padding "0 1" --border-foreground 6 \ "Synthetic Windows disk ready" \ "Disk: $TEST_DISK" \ - "Layout: 512MiB Windows ESP + existing data + 64GiB+ free" + "Layout: $LAYOUT_LABEL" if gum confirm "Start installation on this disk?"; then ISO="$(select_iso)" diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 2b1788b..4cd3db3 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -24,6 +24,17 @@ if [[ ! -r $SETUP_FORM ]]; then fi source "$SETUP_FORM" +# Partition creation helpers, shared with test/partition-numbering-test.sh so +# the numbering logic can be exercised without a real disk. +DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}" +[[ -r $DISK_PARTITIONING ]] || + DISK_PARTITIONING="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../usr/share/omarchy-iso/disk-partitioning.sh" +if [[ ! -r $DISK_PARTITIONING ]]; then + echo "ERROR: disk partitioning helpers not found at $DISK_PARTITIONING" >&2 + exit 1 +fi +source "$DISK_PARTITIONING" + LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH") export GUM_CONFIRM_PROMPT_FOREGROUND="6" @@ -361,15 +372,7 @@ get_root_disk() { fi } -# Compute partition device path, handling NVMe/mmcblk's pN naming. -partition_path() { - local _disk="$1" _num="$2" - if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then - echo "${_disk}p${_num}" - else - echo "${_disk}${_num}" - fi -} +# partition_path lives in disk-partitioning.sh alongside the creation helpers. # BitLocker writes the "-FVE-FS-" signature at offset 3 of the partition. detect_bitlocker() { @@ -416,13 +419,20 @@ detect_kernel() { fi } -# Give udev a moment to create a device node after partitioning. -wait_for_device() { - local i - for i in 1 2 3 4 5; do - [[ -b "$1" ]] && return 0 - sleep 1 - done +# wait_for_device lives in disk-partitioning.sh: it now reports failure, and +# every caller here aborts on it. + +# disk_step and create_partition fail through this rather than abort() so a +# half-built layout is undone before the user is handed back their disk. +disk_abort_hook() { + # Release the target before reclaiming its partitions: a mounted filesystem + # or an open LUKS mapping would keep parted from removing them. + umount -R /mnt 2>/dev/null || true + umount /mnt/btrfs-root 2>/dev/null || true + rmdir /mnt/btrfs-root 2>/dev/null || true + cryptsetup close omarchy_root 2>/dev/null || true + rollback_created_parts "$disk" + abort "$1" } # Save the configurator answers the installer consumes: git identity and @@ -654,17 +664,17 @@ run_partition_decide() { kernel_choice=$(detect_kernel) - local last_part - last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1) - efi_part_num=$(( ${last_part:-0} + 1 )) - root_part_num=$(( efi_part_num + 1 )) - - efi_dev=$(partition_path "$disk" "$efi_part_num") - root_partition_device=$(partition_path "$disk" "$root_part_num") + # Partition numbers are not knowable until parted assigns them (it fills the + # lowest free GPT slot, and freeing space by deleting a partition leaves one + # below the highest). run_partition_execute reads them back after creation; + # a dry run has nothing to read. + efi_part_num="" + root_part_num="" + efi_dev="" + root_partition_device="" + root_mapper="" if [[ "$encrypt_installation" == "true" ]]; then root_mapper="/dev/mapper/omarchy_root" - else - root_mapper="$root_partition_device" fi } @@ -672,64 +682,103 @@ run_partition_decide() { # the pre-mounted (protected) install configuration. Runs after the user step # so LUKS can be formatted with the user's password. run_partition_execute() { - local dry=false + local dry=false subvol [[ "${1:-}" == "dry" ]] && dry=true if $dry; then $needs_mklabel && say "[dry] would initialize GPT on $disk" - say "[dry] would create root partition at $root_partition_device" - say "[dry] would create EFI partition at $efi_dev" + say "[dry] would create an EFI partition and a root partition in the free region" + say "[dry] partition numbers are assigned by parted at creation, then read back" say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice" else step "Creating partitions on $disk" if $needs_mklabel; then - parted --script "$disk" mklabel gpt + disk_step "initializing GPT on $disk" parted --script "$disk" mklabel gpt partprobe "$disk" 2>/dev/null || true sleep 1 fi - parted --script "$disk" mkpart primary fat32 "${EFI_START_B}B" "${EFI_END_B}B" - parted --script "$disk" set "$efi_part_num" esp on - parted --script "$disk" name "$efi_part_num" "OMARCHY_EFI" - parted --script "$disk" mkpart primary btrfs "${ROOT_START_B}B" "${ROOT_END_B}B" - parted --script "$disk" name "$root_part_num" "OMARCHY_ROOT" + + # create_partition reports the number parted actually used; nothing here + # may assume it. It sets created_partition_number rather than printing so + # the rollback bookkeeping survives (a $(...) would subshell it away). + create_partition "$disk" "$EFI_START_B" "$EFI_END_B" fat32 OMARCHY_EFI || + disk_abort_hook "Could not create the EFI partition on $disk" + efi_part_num="$created_partition_number" + + create_partition "$disk" "$ROOT_START_B" "$ROOT_END_B" btrfs OMARCHY_ROOT || + disk_abort_hook "Could not create the root partition on $disk" + root_part_num="$created_partition_number" + + disk_step "flagging partition $efi_part_num as ESP" \ + parted --script "$disk" set "$efi_part_num" esp on + + efi_dev=$(partition_path "$disk" "$efi_part_num") + root_partition_device=$(partition_path "$disk" "$root_part_num") + partprobe "$disk" sync sleep 2 - wait_for_device "$efi_dev" - wait_for_device "$root_partition_device" + wait_for_device "$efi_dev" || disk_abort_hook "EFI partition $efi_dev never appeared" + wait_for_device "$root_partition_device" || + disk_abort_hook "Root partition $root_partition_device never appeared" + + # Both partitions are ours and brand new, but the space they occupy may + # carry signatures from whatever was deleted to free it. + disk_step "clearing stale signatures on $efi_dev" wipefs -af "$efi_dev" + disk_step "clearing stale signatures on $root_partition_device" wipefs -af "$root_partition_device" if [[ "$encrypt_installation" == "true" ]]; then step "Setting up LUKS2 on $root_partition_device" - printf "%s" "$password" | cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" - - printf "%s" "$password" | cryptsetup open "$root_partition_device" omarchy_root - + # Kept as pipes rather than routed through disk_step: the passphrase must + # not become an argv the process table can show. Folding stderr into + # stdout still lands any error in the install log. + if ! printf "%s" "$password" | + cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" - 2>&1; then + disk_abort_hook "Formatting LUKS2 on $root_partition_device failed" + fi + if ! printf "%s" "$password" | + cryptsetup open "$root_partition_device" omarchy_root - 2>&1; then + disk_abort_hook "Opening the encrypted root on $root_partition_device failed" + fi root_mapper="/dev/mapper/omarchy_root" else - wipefs -af "$root_partition_device" root_mapper="$root_partition_device" fi step "Creating Btrfs filesystem and subvolumes" - mkfs.btrfs -f -L OMARCHY "$root_mapper" - wait_for_device "$root_mapper" + disk_step "creating the Btrfs filesystem on $root_mapper" \ + mkfs.btrfs -f -L OMARCHY "$root_mapper" + wait_for_device "$root_mapper" || disk_abort_hook "Root device $root_mapper never appeared" mkdir -p /mnt/btrfs-root - mount "$root_mapper" /mnt/btrfs-root - btrfs subvolume create /mnt/btrfs-root/@ - btrfs subvolume create /mnt/btrfs-root/@home - btrfs subvolume create /mnt/btrfs-root/@log - btrfs subvolume create /mnt/btrfs-root/@pkg + disk_step "mounting $root_mapper" mount "$root_mapper" /mnt/btrfs-root + for subvol in @ @home @log @pkg; do + disk_step "creating subvolume $subvol" btrfs subvolume create "/mnt/btrfs-root/$subvol" + done umount /mnt/btrfs-root rmdir /mnt/btrfs-root - mount -o noatime,compress=zstd,subvol=@ "$root_mapper" /mnt + disk_step "mounting the target root" \ + mount -o noatime,compress=zstd,subvol=@ "$root_mapper" /mnt mkdir -p /mnt/home /mnt/var/log /mnt/var/cache/pacman/pkg /mnt"$esp_mount_in_target" - mount -o noatime,compress=zstd,subvol=@home "$root_mapper" /mnt/home - mount -o noatime,compress=zstd,subvol=@log "$root_mapper" /mnt/var/log - mount -o noatime,compress=zstd,subvol=@pkg "$root_mapper" /mnt/var/cache/pacman/pkg + disk_step "mounting /home" \ + mount -o noatime,compress=zstd,subvol=@home "$root_mapper" /mnt/home + disk_step "mounting /var/log" \ + mount -o noatime,compress=zstd,subvol=@log "$root_mapper" /mnt/var/log + disk_step "mounting the package cache" \ + mount -o noatime,compress=zstd,subvol=@pkg "$root_mapper" /mnt/var/cache/pacman/pkg - mkfs.fat -F32 -n OMARCHY_EFI "$efi_dev" - mount "$efi_dev" /mnt"$esp_mount_in_target" + disk_step "creating the ESP filesystem on $efi_dev" mkfs.fat -F32 -n OMARCHY_EFI "$efi_dev" + disk_step "mounting the ESP" mount "$efi_dev" /mnt"$esp_mount_in_target" + + # The orchestrator's first act is to verify this handoff. Check it here so + # a failure names the step that broke rather than surfacing later as + # "protected mode: /mnt is not a mountpoint". + mountpoint -q /mnt || + disk_abort_hook "Target /mnt is not mounted; not starting the installer" + mountpoint -q "/mnt$esp_mount_in_target" || + disk_abort_hook "ESP is not mounted at /mnt$esp_mount_in_target" fi local luks_uuid_json="null" luks_uuid diff --git a/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh b/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh new file mode 100644 index 0000000..ac818b0 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh @@ -0,0 +1,131 @@ +# Disk partitioning helpers, shared by the ISO configurator and its tests. +# +# The rule here is: never predict a partition number. parted fills the lowest +# free GPT slot, not "highest existing + 1", so any disk whose numbering has a +# hole — exactly what deleting a partition to free space leaves behind, which +# is what our own partition tool tells the user to do — hands back a number we +# did not choose. Everything below reads back what was actually created. +# +# Sourced, not executed. The configurator defines abort() and +# disk_abort_hook(); tests get the plain fallbacks. + +# Partitions this run created, in creation order. rollback_created_parts() +# undoes exactly these and nothing else. +created_parts=() + +# Set by create_partition() instead of being printed: a command substitution +# would run it in a subshell and lose the created_parts bookkeeping. +created_partition_number="" + +# Compute partition device path, handling NVMe/mmcblk's pN naming. +partition_path() { + local _disk="$1" _num="$2" + if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then + echo "${_disk}p${_num}" + else + echo "${_disk}${_num}" + fi +} + +# Partition numbers as the on-disk GPT reports them. parted rather than lsblk +# on purpose: this is the table parted itself is about to modify, so discovery +# never depends on the kernel having re-read the partition table yet — and the +# same code works against an image file in tests, where lsblk sees nothing. +partition_numbers() { + parted -ms "$1" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1 +} + +partition_size_bytes() { + parted -ms "$1" unit B print 2>/dev/null | + awk -F: -v n="$2" '$1 == n { gsub(/B/, "", $4); print $4; exit }' +} + +# Fail loudly. The former version looped over sleep and returned its status, +# so a device that never appeared was indistinguishable from one that did. +wait_for_device() { + local i + for i in $(seq 1 10); do + [[ -b "$1" ]] && return 0 + udevadm settle 2>/dev/null || true + sleep 1 + done + return 1 +} + +_disk_abort() { + if declare -F disk_abort_hook >/dev/null; then + disk_abort_hook "$1" + elif declare -F abort >/dev/null; then + abort "$1" + fi + echo "Error: $1" >&2 + exit 1 +} + +# Run a disk-writing command, fold its stderr into stdout, and abort on a +# non-zero exit. The fold matters: .automated_script.sh tees stdout into +# /var/log/omarchy-install.log but sends stderr straight to the tty (gum draws +# its TUI there), so an unwrapped failure leaves no trace in the log the user +# uploads — and the configurator's next screen clears it off the display too. +disk_step() { + local desc="$1" + shift + local output status=0 + output=$("$@" 2>&1) || status=$? + [[ -n $output ]] && printf '%s\n' "$output" + (( status == 0 )) && return 0 + _disk_abort "$desc failed (exit $status)" +} + +# Create one partition and report the number parted actually assigned. +# Returns non-zero without touching created_parts if anything looks wrong; +# the caller decides how loudly to fail. +create_partition() { + local disk="$1" start="$2" end="$3" fstype="$4" name="$5" + local before after num actual want tolerance + + created_partition_number="" + + # Lexicographic sort on both sides: comm needs its inputs ordered the same + # way it compares them, and `sort -n` (1, 2, 10) is not that order. + before=$(partition_numbers "$disk" | sort) + + parted --script "$disk" mkpart primary "$fstype" "${start}B" "${end}B" || return 1 + partprobe "$disk" 2>/dev/null || true + udevadm settle 2>/dev/null || true + + after=$(partition_numbers "$disk" | sort) + num=$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -1) + [[ -n $num ]] || return 1 + + # The number must be genuinely new. This is the safety property that keeps a + # numbering mistake from ever formatting a partition somebody else is using; + # it is cheaper and more reliable than sniffing the target for signatures, + # which would false-positive on remnants left in freed space. + grep -qx "$num" <<<"$before" && return 1 + + actual=$(partition_size_bytes "$disk" "$num") + [[ -n $actual ]] || return 1 + want=$((end - start)) + tolerance=$((1024 * 1024)) + (( actual >= want - tolerance && actual <= want + tolerance )) || return 1 + + parted --script "$disk" name "$num" "$name" || true + + created_parts+=("$num") + created_partition_number="$num" +} + +# Undo the partitions this run created, highest number first. Scoped strictly +# to created_parts: without this, a failed install leaves the user's freed +# space occupied by orphans, and the retry reports "not enough free space" +# with no way to connect that to what just happened. +rollback_created_parts() { + local disk="$1" n + (( ${#created_parts[@]} > 0 )) || return 0 + for n in $(printf '%s\n' "${created_parts[@]}" | sort -rn); do + parted --script "$disk" rm "$n" >/dev/null 2>&1 || true + done + partprobe "$disk" 2>/dev/null || true + created_parts=() +} diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 6920e9c..2745139 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -765,20 +765,26 @@ def _storage_intent(ctx: InstallContext) -> dict: def verify_protected_mounts(ctx: InstallContext) -> None: target = ctx.target - if not _is_mountpoint(target): - raise RuntimeError(f"protected mode: {target} is not a mountpoint") - boot = _boot_intent(ctx) storage = _storage_intent(ctx) + + # Devices before the mountpoint: when the configurator hands over paths for + # partitions that do not exist, "root_device /dev/nvme0n1p12 does not + # exist" is diagnosable from the log alone, where "/mnt is not a + # mountpoint" sends everyone looking at the mount instead of the paths. for key in ("esp_device", "root_device"): - if not storage.get(key): + device = storage.get(key) + if not device: raise RuntimeError(f"protected mode: omarchy_install.storage.{key} missing") + if not Path(device).exists(): + raise RuntimeError(f"protected mode: {key} {device} does not exist") + + if not _is_mountpoint(target): + raise RuntimeError(f"protected mode: {target} is not a mountpoint") esp_mp = target / boot["esp_mount"].lstrip("/") if not _is_mountpoint(esp_mp): esp_dev = storage["esp_device"] - if not Path(esp_dev).exists(): - raise RuntimeError(f"protected mode: ESP device {esp_dev} does not exist") info(f"› remounting protected ESP {esp_dev} at {esp_mp}") esp_mp.mkdir(parents=True, exist_ok=True) subprocess.run(["mount", esp_dev, str(esp_mp)], check=True) diff --git a/test/partition-numbering-test.sh b/test/partition-numbering-test.sh new file mode 100644 index 0000000..29ad90b --- /dev/null +++ b/test/partition-numbering-test.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# +# create_partition() must report the number parted actually assigned, never a +# predicted one. The regression this pins: the configurator used to compute +# "highest existing partition + 1", while parted fills the lowest free GPT +# slot. Any disk whose numbering has a hole — what deleting a partition to +# free space leaves behind — then had every later command (cryptsetup, mkfs, +# mount) pointed at device nodes that did not exist, surfacing much later as +# "protected mode: /mnt is not a mountpoint". +# +# parted operates on image files directly, so this needs no root, no loop +# devices, and no real disk. + +set -uo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +LIB="$ROOT/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh" + +if ! command -v parted >/dev/null 2>&1; then + echo "SKIP: parted is not installed" + exit 0 +fi + +# shellcheck source=../configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh +source "$LIB" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +IMG="$WORK/disk.img" +MIB=$((1024 * 1024)) +failures=0 + +check() { + local label="$1" expected="$2" actual="$3" + if [[ $expected == "$actual" ]]; then + printf ' ok %s\n' "$label" + else + printf ' FAIL %s: expected %s, got %s\n' "$label" "$expected" "$actual" + failures=$((failures + 1)) + fi +} + +# parted reports a partition's size inclusive of both endpoints, so it lands +# one sector above (end - start). Same slack create_partition allows. +check_size() { + local label="$1" want="$2" actual="${3:-0}" delta + delta=$((actual - want)) + (( delta < 0 )) && delta=$((-delta)) + if (( delta <= MIB )); then + printf ' ok %s\n' "$label" + else + printf ' FAIL %s: expected ~%s, got %s\n' "$label" "$want" "$actual" + failures=$((failures + 1)) + fi +} + +# parted's end position is inclusive, so partitions laid back to back overlap +# by a sector and are refused. Leave a MiB between them. +mkpart_mib() { + parted --script "$IMG" mkpart "$1" "$2" "$(($3 * MIB))B" "$(($4 * MIB))B" +} + +# A disk with holes: four partitions, then delete the middle two. Slots 2 and +# 3 are free while the highest number in use is 4 — the shape of the reported +# failure, where the user deleted a partition to make room. +build_holey_disk() { + rm -f "$IMG" + truncate -s 4G "$IMG" + parted --script "$IMG" mklabel gpt + mkpart_mib one ext4 1 200 + mkpart_mib two ext4 201 400 + mkpart_mib three ext4 401 600 + mkpart_mib four ext4 601 800 + parted --script "$IMG" rm 2 + parted --script "$IMG" rm 3 + created_parts=() +} + +echo "==> numbering on a disk with holes" +build_holey_disk +check "existing numbers" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')" + +create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI +check "create_partition succeeded (esp)" "0" "$?" +esp_num="$created_partition_number" +check "esp took the lowest free slot" "2" "$esp_num" + +# Starts a MiB past the ESP, the way run_partition_decide aligns ROOT_START_B +# up from EFI_END_B + 1. +create_partition "$IMG" "$((1201 * MIB))" "$((2000 * MIB))" btrfs OMARCHY_ROOT +check "create_partition succeeded (root)" "0" "$?" +root_num="$created_partition_number" +check "root took the next free slot" "3" "$root_num" + +# The old prediction. If either of these ever matches again, the regression is +# back: the highest number in use was 4, so the guess would have been 5 and 6. +check "esp is not the predicted number" "not-5" "$([[ $esp_num == 5 ]] && echo 5 || echo not-5)" +check "root is not the predicted number" "not-6" "$([[ $root_num == 6 ]] && echo 6 || echo not-6)" + +check "both creations tracked for rollback" "2 3" "${created_parts[*]}" +check_size "esp size" "$((200 * MIB))" "$(partition_size_bytes "$IMG" "$esp_num")" +check_size "root size" "$((799 * MIB))" "$(partition_size_bytes "$IMG" "$root_num")" + +echo "==> rollback reclaims only what this run created" +rollback_created_parts "$IMG" +check "created partitions removed" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')" +check "rollback list cleared" "0" "${#created_parts[@]}" + +echo "==> numbering on a disk without holes" +rm -f "$IMG" +truncate -s 4G "$IMG" +parted --script "$IMG" mklabel gpt +mkpart_mib one ext4 1 200 +mkpart_mib two ext4 201 400 +created_parts=() + +create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI +check "appends when there is no hole" "3" "$created_partition_number" + +echo "==> a refused creation stays out of the rollback list" +created_parts=() +create_partition "$IMG" "$((100 * MIB))" "$((300 * MIB))" ext4 OVERLAP +check "overlapping creation failed" "1" "$?" +check "nothing tracked" "0" "${#created_parts[@]}" + +if (( failures > 0 )); then + printf '\n%d check(s) failed\n' "$failures" + exit 1 +fi +printf '\nall checks passed\n'