diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 16904f87..cbfd600c 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -91,6 +91,23 @@ warm_pid=$! trap 'kill "$warm_pid" 2>/dev/null' EXIT cd /root +# A run that aborted — a refused cfdisk Resize, a failed partition snapshot, +# Ctrl+C — leaves its inputs behind, and the retry it recommends comes straight +# back here. Clear them first so the gate below can only ever pass on a +# configuration this run produced, never on one describing a partition layout +# that no longer exists. +# +# This is the same set omarchy-cidata-load clears before it copies, and it has +# to stay the same set. Its cleanup only runs once a cidata drive is found, so +# an autoinstall attempt that aborts and is retried with the drive pulled never +# reaches it: authorized_keys and tailscale_authkey would survive into the +# interactive install, which passes both to the orchestrator whenever the files +# exist, and the new machine would authorize the rig's SSH key and enroll with +# its Tailscale key. +rm -f user_configuration.json user_credentials.json defer-provisioning \ + user_full_name.txt user_email_address.txt user_encrypt_installation.txt \ + authorized_keys tailscale_authkey + # Autoinstall: a cidata drive carrying the configurator's own output files # stands in for the wizard. omarchy-cidata-load copies them into /root and # everything downstream runs the ordinary path against ordinary inputs. @@ -98,9 +115,18 @@ if /usr/local/bin/omarchy-cidata-load; then echo "Autoinstall configuration found on cidata drive; skipping the configurator." export OMARCHY_UI_INTERACTIVE=no else - ./configurator + # The else body already inherits set -e, but the dashboard launch below is + # unconditional and the guards it stands between are data-safety guards. + # State the contract in the code so a configurator abort (a Resize of an + # existing OS, a table that would not snapshot) cannot start an install. + ./configurator || exit 1 fi +# Both branches must have produced a configuration. Nothing downstream re-reads +# the partition table, so an install started without one would run against +# whatever the disk happens to look like now. +[[ -f user_configuration.json ]] || exit 1 + # Deferred-provisioning installs skip the celebration/reboot prompt and reboot on # their own — the owner completes setup at first boot. Signalled by the config's # defer_provisioning flag (interactive) or the defer-provisioning marker (cidata). diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 4cd3db3d..616ea617 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -507,6 +507,7 @@ not_enough_space() { step "Not enough free space on $disk" say --foreground 1 "$disk has $(to_gb $available) of usable free space; Omarchy needs at least 32GB." say "Open the partition tool to free at least 32GB on $disk, then try again." + say --foreground 8 "Do not Resize an existing OS partition in cfdisk — that only changes the table and corrupts the filesystem." echo if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then open_partition_tool @@ -895,18 +896,67 @@ disk_form() { # STEP 4: INSTALL MODE open_partition_tool() { + local dump="" before status + step "Partition tool for $disk" gum style "Create unallocated free space for Omarchy, then write changes and quit." gum style --foreground 8 "Do not create an Omarchy partition here — leave the target area as Free space." + gum style --foreground 8 "Do not Resize an existing OS partition. cfdisk does not shrink filesystems; that corrupts the other OS." + gum style --foreground 8 "Shrink from Windows Disk Management (or the running OS) first, then leave the gap as Free space." gum style --foreground 8 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists." echo gum confirm --affirmative "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0 + dump=$(mktemp) + # The baseline scan and the snapshot are prerequisites, not conveniences. + # Without both there is nothing to compare cfdisk's result against and + # nothing to put back, so a Resize of an existing OS would go unnoticed and + # unrecoverable. Refuse to open the tool rather than open it unguarded. + if ! before=$(partition_starts_and_sizes "$disk") || ! save_partition_table "$disk" "$dump"; then + rm -f "$dump" + step "Cannot open the partition tool" + say --foreground 1 "The current partition table on $disk could not be read and saved, so a Resize could not be undone." + say "Make free space from Windows Disk Management (or the other OS) instead, then return here." + say --foreground 8 "Leave the space you free as unallocated Free space." + echo + gum confirm --affirmative "OK" --negative "Quit" "Return to installation mode?" || abort + return 1 + fi + clear cfdisk "$disk" || true partprobe "$disk" 2>/dev/null || true sync sleep 1 + + restore_shrunk_partitions "$disk" "$dump" "$before" + status=$? + rm -f "$dump" + if (( status == 0 )); then + partprobe "$disk" 2>/dev/null || true + sync + step "Partition table restored" + say --foreground 1 "cfdisk Resize only changes the partition table. It does not shrink the filesystem." + say "Continuing would have corrupted the existing OS, so the previous partition table was restored." + say --foreground 8 "Make free space from Windows Disk Management (or the other OS), then return here." + say --foreground 8 "Deleting an unused partition in cfdisk is fine. Resizing one is not." + echo + if gum confirm --affirmative "OK" --negative "Quit" "Return to installation mode?"; then + return 1 + fi + abort + elif (( status == 2 )); then + step "Partition resize detected" + say --foreground 1 "cfdisk Resize only changes the partition table. It does not shrink the filesystem." + say "The previous table could not be restored automatically. Do not continue this install." + abort "Aborted: shrinking an existing partition would corrupt the other OS." + elif (( status == 3 )); then + step "Partition table could not be verified" + say --foreground 1 "The partition table on $disk could not be read back after cfdisk." + say "A Resize of an existing OS cannot be ruled out, and installing into that space would corrupt it." + say "Do not continue this install. Reboot and check the disk from the other OS." + abort "Aborted: the partition table could not be verified after cfdisk." + fi } # If the disk has no partition table or no unallocated free space, the only diff --git a/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh b/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh index ac818b00..e385977f 100644 --- a/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh +++ b/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh @@ -116,6 +116,80 @@ create_partition() { created_partition_number="$num" } +# Start sector and size in bytes for every partition, one "start size" line. +# Start is the identity cfdisk Resize preserves (it changes the end, not the +# start), so a later comparison can tell a shrink from a delete or a new +# partition in free space. parted rather than lsblk so this works against +# image files in tests, the same way partition_numbers does. +partition_starts_and_sizes() { + local out + # Judge parted by its exit status, not by what it printed. Reading the table + # can fail, and a failure that arrives as empty output is indistinguishable + # from a disk with no partitions — which reads as "nothing shrank" and is + # exactly how a guard like this fails open. Capture first so the status is + # parted's own and not awk's. + out=$(parted -ms "$1" unit B print 2>/dev/null) || return 1 + printf '%s\n' "$out" | tail -n +3 | awk -F: ' + $1 ~ /^[0-9]+$/ { + start=$2; size=$4; + gsub(/B/, "", start); + gsub(/B/, "", size); + print start, size + }' +} + +# Lines of "start old_size new_size" for partitions that shrank by more than +# 1MiB. Matched by start sector so a delete (start gone) or a newly created +# partition (start unseen) is not a shrink. The 1MiB slack is the same +# alignment tolerance create_partition uses. +shrunk_partition_lines() { + local before="$1" after="$2" + awk -v tol=$((1024 * 1024)) ' + NR == FNR { old[$1] = $2; next } + ($1 in old) && ((old[$1] - $2) > tol) { print $1, old[$1], $2 } + ' <(printf '%s\n' "$before") <(printf '%s\n' "$after") +} + +# Snapshot the GPT so a later cfdisk session can be undone. Fails when sfdisk +# fails or writes nothing usable (no table, sfdisk unavailable, unreadable +# disk): a partial dump would restore a table nobody verified, so the caller +# must treat a false here as "do not let cfdisk near this disk". +save_partition_table() { + local disk="$1" dest="$2" status=0 + sfdisk -d "$disk" >"$dest" 2>/dev/null || status=$? + (( status == 0 )) && [[ -s $dest ]] +} + +# Rewrite the GPT from an sfdisk dump. Used to undo a cfdisk Resize: that +# command only changes the partition table, so putting the original table +# back is a full recovery as long as nothing has been written into the gap. +restore_partition_table() { + local disk="$1" src="$2" output status=0 + output=$(sfdisk --force "$disk" <"$src" 2>&1) || status=$? + [[ -n $output ]] && printf '%s\n' "$output" + partprobe "$disk" 2>/dev/null || true + return "$status" +} + +# If any existing partition shrank, restore dump and return 0. Return 1 when +# the table is fine (delete/create/grow/no-op). Return 2 when a shrink was +# found but the dump could not be written back. Return 3 when the edit cannot +# be judged at all: no usable snapshot to compare against or restore from, or +# a table that no longer reads back. +# +# 3 must never collapse into 1. "Cannot tell" is precisely the state in which +# a shrink is invisible, and answering "the table is fine" there hands the +# installer a disk whose existing filesystem may already be past its new end. +restore_shrunk_partitions() { + local disk="$1" dump="$2" before="$3" after shrunk + [[ -n $dump && -s $dump ]] || return 3 + after=$(partition_starts_and_sizes "$disk") || return 3 + shrunk=$(shrunk_partition_lines "$before" "$after") + [[ -n $shrunk ]] || return 1 + restore_partition_table "$disk" "$dump" || return 2 + return 0 +} + # 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" diff --git a/test/unit/installer-gate-test.sh b/test/unit/installer-gate-test.sh new file mode 100755 index 00000000..41c75673 --- /dev/null +++ b/test/unit/installer-gate-test.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# +# The partition guard in the configurator is only worth anything if the script +# that launches it honours a refusal. .automated_script.sh hands off to the +# install dashboard unconditionally once the wizard returns, so these cases +# pin the two gates that stand between: the configurator's exit status, and +# the configuration file it is supposed to have produced. +# +# The launcher is a tty1-gated monolith that redirects its own output and +# execs the real installer, so the handoff region is lifted out and run in a +# sandbox with /root, /usr/local/bin and /run rewritten to throwaway paths. +# The lines under test are the file's own, unmodified. + +set -uo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +LAUNCHER="$ROOT/configs/airootfs/root/.automated_script.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +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 +} + +# Rebuild the sandbox and the handoff script for each case. CIDATA_EXIT and +# CONFIGURATOR_EXIT drive which branch runs; CONFIGURATOR_WRITES_CONFIG says +# whether the wizard got far enough to leave a configuration behind. +new_sandbox() { + sandbox=$(mktemp -d "$work/sandbox.XXXXXX") + mkdir -p "$sandbox/root" "$sandbox/bin" "$sandbox/run" + + cat >"$sandbox/bin/omarchy-cidata-load" <<'STUB' +#!/bin/bash +exit "${CIDATA_EXIT:-1}" +STUB + + cat >"$sandbox/root/configurator" <<'STUB' +#!/bin/bash +[[ ${CONFIGURATOR_WRITES_CONFIG:-0} == 1 ]] && echo '{"disk_config": {}}' >user_configuration.json +exit "${CONFIGURATOR_EXIT:-0}" +STUB + + # The marker is the whole assertion: if it exists, an install was started. + cat >"$sandbox/bin/omarchy-install-dashboard" <<'STUB' +#!/bin/bash +touch "$SANDBOX/install-started" +STUB + + cat >"$sandbox/bin/jq" <<'STUB' +#!/bin/bash +echo false +STUB + + # tty is read for the dashboard's TTY handle; the test has no terminal. + cat >"$sandbox/bin/tty" <<'STUB' +#!/bin/bash +echo /dev/console +STUB + + chmod +x "$sandbox/bin"/* "$sandbox/root/configurator" + + sed -n '/^cd \/root$/,$p' "$LAUNCHER" | + sed -e "s#^cd /root\$#cd $sandbox/root#" \ + -e "s#/usr/local/bin/#$sandbox/bin/#g" \ + -e "s#/root/#$sandbox/root/#g" \ + -e "s#/run/omarchy-install/#$sandbox/run/#g" \ + >"$sandbox/handoff.sh" + + # The lift has to keep the pieces under test. A silent sed miss would turn + # every case into a vacuous pass. + grep -q 'configurator || exit 1' "$sandbox/handoff.sh" && + grep -q 'user_configuration.json \]\] || exit 1' "$sandbox/handoff.sh" && + grep -q "$sandbox/bin/omarchy-install-dashboard" "$sandbox/handoff.sh" +} + +run_handoff() { + SANDBOX="$sandbox" \ + OMARCHY_INSTALL_LOG_FILE="$sandbox/install.log" \ + PATH="$sandbox/bin:$PATH" \ + bash "$sandbox/handoff.sh" >/dev/null 2>&1 +} + +installed() { + [[ -e $sandbox/install-started ]] && echo yes || echo no +} + +echo "==> the handoff region is lifted intact" +new_sandbox +check "both gates and the dashboard handoff survive the rewrite" "0" "$?" + +echo "==> a configurator that refuses does not start an install" +new_sandbox +CONFIGURATOR_EXIT=1 CONFIGURATOR_WRITES_CONFIG=0 run_handoff +check "the launcher exits nonzero" "1" "$?" +check "no install was started" "no" "$(installed)" + +# The abort path tells the user to re-run the launcher. A configuration left +# by the run that just refused describes a partition layout the guard rejected. +echo "==> a stale configuration cannot stand in for a refused one" +new_sandbox +echo '{"disk_config": {}}' >"$sandbox/root/user_configuration.json" +CONFIGURATOR_EXIT=1 CONFIGURATOR_WRITES_CONFIG=0 run_handoff +check "the launcher still exits nonzero" "1" "$?" +check "no install was started" "no" "$(installed)" +[[ -e $sandbox/root/user_configuration.json ]] +check "the stale configuration was cleared" "1" "$?" + +# An autoinstall attempt that aborts and is retried with the cidata drive +# pulled never reaches the loader's own cleanup — it exits as soon as no drive +# is found. The orchestrator treats these files as "present means use it", so +# anything left here is the previous attempt's remote access on the new machine. +echo "==> a pulled cidata drive leaves no remote-access credentials behind" +new_sandbox +echo 'ssh-ed25519 AAAA rig@imaging' >"$sandbox/root/authorized_keys" +echo 'tskey-auth-rig' >"$sandbox/root/tailscale_authkey" +echo '{"disk_config": {}}' >"$sandbox/root/user_configuration.json" +: >"$sandbox/root/defer-provisioning" +CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=1 run_handoff +check "the interactive retry starts its own install" "yes" "$(installed)" +[[ -e $sandbox/root/authorized_keys ]] +check "the rig's SSH key was cleared" "1" "$?" +[[ -e $sandbox/root/tailscale_authkey ]] +check "the rig's Tailscale key was cleared" "1" "$?" +[[ -e $sandbox/root/defer-provisioning ]] +check "the stale defer-provisioning marker was cleared" "1" "$?" + +# The launcher clears these because omarchy-cidata-load cannot: its cleanup is +# behind the drive-found check. Drift between the two lists reopens the leak, +# so compare them rather than trusting two hand-maintained copies. +echo "==> the launcher clears every input the cidata loader knows about" +CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load" +loader_inputs=$( + sed -n 's/^optional_inputs=(\(.*\))$/\1/p' "$CIDATA_LOAD" | + tr ' ' '\n' | sed '/^$/d' | sort -u +) +loader_inputs=$(printf '%s\nuser_configuration.json\n' "$loader_inputs" | sort -u) +launcher_inputs=$( + awk '/^rm -f / { grab = 1 } + grab { sub(/^rm -f /, ""); cont = /\\$/; sub(/\\$/, ""); print; if (!cont) exit }' "$LAUNCHER" | + tr ' ' '\n' | sed '/^$/d' | sort -u +) +check "the loader's input list was parsed" "0" "$([[ -n $loader_inputs ]] && echo 0 || echo 1)" +check "the two cleanup lists match" "$loader_inputs" "$launcher_inputs" + +echo "==> a configurator that produced nothing does not start an install" +new_sandbox +CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=0 run_handoff +check "the launcher exits nonzero" "1" "$?" +check "no install was started" "no" "$(installed)" + +echo "==> a completed wizard still starts the install" +new_sandbox +CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=1 run_handoff +check "the launcher exits clean" "0" "$?" +check "the install was started" "yes" "$(installed)" + +echo "==> autoinstall from a cidata drive still starts the install" +new_sandbox +echo '{"disk_config": {}}' >"$sandbox/root/cidata-config" +cat >"$sandbox/bin/omarchy-cidata-load" < 0 )); then + printf '\n%d check(s) failed\n' "$failures" + exit 1 +fi +printf '\nall checks passed\n' diff --git a/test/unit/partition-shrink-guard-test.sh b/test/unit/partition-shrink-guard-test.sh new file mode 100644 index 00000000..4b3c2a7e --- /dev/null +++ b/test/unit/partition-shrink-guard-test.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# +# cfdisk Resize rewrites the GPT and leaves the filesystem at its old size. +# That is the dual-boot trap: shrinking Windows/NTFS or an existing Linux +# /home from the partition tool corrupts the other OS. The guard must restore +# the original table when an existing partition shrank, and leave the table +# alone for the operations the tool actually tells the user to do — delete +# something unused, or create nothing and leave Free space. +# +# sfdisk and parted both operate on image files, 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 +if ! command -v sfdisk >/dev/null 2>&1; then + echo "SKIP: sfdisk 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'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" +} + +build_disk() { + rm -f "$IMG" + truncate -s 4G "$IMG" + parted --script "$IMG" mklabel gpt + mkpart_mib one ext4 1 800 + mkpart_mib two ext4 801 1200 + mkpart_mib three ext4 1201 1600 +} + +# Rewrite the first partition's size in an sfdisk dump and apply it — the same +# kind of table-only shrink cfdisk Resize performs. +shrink_first_partition_sectors() { + local sectors="$1" dump + dump=$(mktemp) + sfdisk -d "$IMG" >"$dump" + awk -v sz="$sectors" ' + !done && /^[^#].*size=/ { + sub(/size=[[:space:]]*[0-9]+/, "size=" sz) + done=1 + } + { print } + ' "$dump" | sfdisk --force "$IMG" >/dev/null 2>&1 + rm -f "$dump" +} + +layout() { + partition_starts_and_sizes "$IMG" | tr '\n' ' ' | sed 's/ $//' +} + +echo "==> shrinking an existing partition is detected and restored" +build_disk +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +check "sfdisk dump captured" "0" "$?" +original=$(layout) + +# 400MiB in 512-byte sectors. +shrink_first_partition_sectors 819200 +check "shrink changed the table" "changed" "$([[ $(layout) == "$original" ]] && echo same || echo changed)" +shrunk=$(shrunk_partition_lines "$before" "$(partition_starts_and_sizes "$IMG")") +check "shrink reported a line" "has-shrink" "$([[ -n $shrunk ]] && echo has-shrink || echo none)" + +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "restore_shrunk_partitions reports restored" "0" "$?" +check "original sizes are back" "$original" "$(layout)" + +echo "==> deleting a partition to make free space is not a shrink" +build_disk +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +parted --script "$IMG" rm 2 +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "delete is left alone" "1" "$?" +check "remaining numbers" "1 3" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')" + +echo "==> creating a partition in free space is not a shrink" +build_disk +parted --script "$IMG" rm 3 +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +mkpart_mib three ext4 2000 2500 +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "create is left alone" "1" "$?" +check "new partition exists" "1 2 3" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')" + +echo "==> growing a partition is not a shrink" +build_disk +parted --script "$IMG" rm 3 +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +# Grow partition 2 into the hole left by 3. parted resizepart prompts on +# shrink, not on grow. +parted --script "$IMG" resizepart 2 "$((2000 * MIB))B" +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "grow is left alone" "1" "$?" + +echo "==> no table change is not a shrink" +build_disk +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "no-op is left alone" "1" "$?" +check "layout unchanged" "$before" "$(partition_starts_and_sizes "$IMG")" + +# Everything above is the guard working. The rest is the guard failing, which +# is where a data-safety check earns its keep: every way of not knowing must +# come back as "do not continue", never as the "table is fine" status that +# lets the install proceed. +echo "==> a table that cannot be read or snapshotted is refused, not assumed safe" + +save_partition_table "$WORK/missing.img" "$WORK/empty.dump" +check "save_partition_table rejects a disk it cannot read" "1" "$?" +[[ -s $WORK/empty.dump ]] +check "a failed dump is not accepted as a snapshot" "1" "$?" + +partition_starts_and_sizes "$WORK/missing.img" >/dev/null +check "partition_starts_and_sizes reports an unreadable disk" "1" "$?" + +build_disk +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +check "snapshot of a real table succeeds" "0" "$?" + +restore_shrunk_partitions "$IMG" "" "$before" +check "no snapshot is 'cannot tell', not 'fine'" "3" "$?" +restore_shrunk_partitions "$IMG" "$WORK/empty.dump" "$before" +check "an empty snapshot is 'cannot tell', not 'fine'" "3" "$?" + +# The restore itself can fail — a disk that went away mid-session, an sfdisk +# that refuses the dump. The shrink is real and still on the table at that +# point, so this must never look like the untouched-table case. +echo "==> a shrink that cannot be undone stops the install" + +build_disk +before=$(partition_starts_and_sizes "$IMG") +echo "not an sfdisk dump" >"$WORK/corrupt.dump" +shrink_first_partition_sectors 400000 +restore_shrunk_partitions "$IMG" "$WORK/corrupt.dump" "$before" >/dev/null 2>&1 +check "a failed restore is reported as a failed restore" "2" "$?" +[[ $(layout) == "$before" ]] +check "the shrink is still on the table after a failed restore" "1" "$?" + +build_disk +before=$(partition_starts_and_sizes "$IMG") +dump="$WORK/table.dump" +save_partition_table "$IMG" "$dump" +truncate -s 0 "$IMG" +restore_shrunk_partitions "$IMG" "$dump" "$before" +check "a table that will not read back is 'cannot tell', not 'fine'" "3" "$?" + +if (( failures > 0 )); then + printf '\n%d check(s) failed\n' "$failures" + exit 1 +fi +printf '\nall checks passed\n'