diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-20 15:38:36 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-20 15:38:36 -0500 |
| commit | aef074f711566159d200021f7133f7713f2aaf8d (patch) | |
| tree | 1c3ff1863005a145d39b7ca3d14fb0cd2dd6d53c | |
| parent | f5c0bf068f34b195d6e47ebff8a4837dc4073874 (diff) | |
| download | archsetup-aef074f711566159d200021f7133f7713f2aaf8d.tar.gz archsetup-aef074f711566159d200021f7133f7713f2aaf8d.zip | |
fix(installer): harden the disk-space pre-flight gate
`df /` wraps to two lines when the root device name is long -- a device-mapper or live-ISO root -- so `awk 'NR==2 {print $4}'` read the wrapped device line, found an empty Available field, and aborted a valid install with "Insufficient disk space." `df -P` forces POSIX single-line output.
The gate now compares available KB against the minimum in KB rather than truncating to GB first, which rejected a disk sitting just under a whole-GB boundary. Non-numeric df output falls back to zero so a malformed read aborts loudly instead of crashing the arithmetic test.
Extracted the logic into check_disk_space so it can be exercised directly.
| -rwxr-xr-x | archsetup | 33 | ||||
| -rw-r--r-- | tests/installer-steps/test_check_disk_space.py | 94 |
2 files changed, 118 insertions, 9 deletions
@@ -480,20 +480,35 @@ nvidia_preflight_report() { return 11 } -preflight_checks() { - echo "Running pre-flight checks..." - - # Check disk space (need at least 20GB free on root partition) - available_kb=$(df / | awk 'NR==2 {print $4}') - available_gb=$((available_kb / 1024 / 1024)) - if [ "$available_gb" -lt "$min_disk_space_gb" ]; then +check_disk_space() { + # df -P forces POSIX single-line output; plain `df` wraps to two lines when + # the source device name is long (live ISO / device-mapper root), which + # made NR==2 read the device line and $4 come back empty. Compare in KB + # directly rather than truncating to GB first, which biased the gate + # against the user near the threshold. + local available_kb min_kb + available_kb=$(df -P / | awk 'NR==2 {print $4}') + # Treat empty or non-numeric output (df failed / malformed) as zero so the + # gate aborts loudly instead of crashing the arithmetic comparison. + case "$available_kb" in + ''|*[!0-9]*) available_kb=0 ;; + esac + min_kb=$((min_disk_space_gb * 1024 * 1024)) + if [ "$available_kb" -lt "$min_kb" ]; then echo "ERROR: Insufficient disk space" echo " Required: ${min_disk_space_gb}GB" - echo " Available: ${available_gb}GB" + echo " Available: $((available_kb / 1024 / 1024))GB" echo " Free up disk space before running archsetup." exit 1 fi - echo " [OK] Disk space: ${available_gb}GB available" + echo " [OK] Disk space: $((available_kb / 1024 / 1024))GB available" +} + +preflight_checks() { + echo "Running pre-flight checks..." + + # Check disk space (need at least 20GB free on root partition) + check_disk_space # Check network connectivity if ! ping -c 1 -W 5 archlinux.org > /dev/null 2>&1; then diff --git a/tests/installer-steps/test_check_disk_space.py b/tests/installer-steps/test_check_disk_space.py new file mode 100644 index 0000000..deec6d9 --- /dev/null +++ b/tests/installer-steps/test_check_disk_space.py @@ -0,0 +1,94 @@ +"""Test the disk-space pre-flight gate. + +Two bugs the extracted check_disk_space fixes: + +1. `df /` wraps to two lines when the source device name is long (common on a + live ISO / device-mapper root). The old `df / | awk 'NR==2 {print $4}'` + then reads the device-name line, gets an empty $4, and wrongly aborts on a + disk with plenty of space. `df -P /` forces POSIX single-line output. +2. The old code truncated KB -> GB with integer division before comparing, + biasing the gate against the user near the threshold. The check now compares + available KB against the minimum in KB directly. + +Method: sed-extract check_disk_space from the real `archsetup`, run it with a +fake df (controlled output, and wrapped output for the plain-df regression) and +assert the gate passes/aborts correctly. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_check_disk_space +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +# Fake df: single, correct POSIX line for `df -P`; a WRAPPED two-line body for +# plain `df`. If check_disk_space ever reverts to plain `df`, the wrapped body +# yields an empty Available field and the ample-space test fails -- that is the +# regression guard for bug 1. +FAKE_DF = textwrap.dedent("""\ + df() {{ + if [ "$1" = "-P" ]; then + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/root {avail} 100 {avail} 1% /" + else + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/a-very-long-device-mapper-name-that-wraps" + printf '%s\\n' " {avail} 100 {avail} 1% /" + fi + }} +""") + + +def run_check(available_kb, min_gb=20): + """Run check_disk_space with fake df reporting available_kb free.""" + script = textwrap.dedent(f"""\ + min_disk_space_gb={min_gb} + {FAKE_DF.format(avail=available_kb)} + source <(sed -n '/^check_disk_space() {{/,/^}}/p' "{ARCHSETUP}") + check_disk_space + echo "REACHED-END" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class CheckDiskSpace(unittest.TestCase): + # min 20GB -> 20 * 1024 * 1024 = 20971520 KB + MIN_KB = 20 * 1024 * 1024 + + def test_ample_space_passes(self): + # ~95GB free; also the wrapped-df regression guard (plain df would + # yield an empty Available and wrongly abort). + r = run_check(99000000) + self.assertIn("REACHED-END", r.stdout) + self.assertIn("[OK] Disk space", r.stdout) + + def test_insufficient_space_aborts(self): + r = run_check(5 * 1024 * 1024) # 5GB + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_boundary_exact_minimum_passes(self): + r = run_check(self.MIN_KB) # exactly 20GB in KB + self.assertIn("REACHED-END", r.stdout) + + def test_boundary_one_kb_under_minimum_aborts(self): + r = run_check(self.MIN_KB - 1) + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_empty_df_output_aborts(self): + # df field unparseable -> treat as zero, abort rather than crash. + r = run_check("") + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + +if __name__ == "__main__": + unittest.main() |
