From 042d7039bec99383ad2332aa8e614eb12780bf98 Mon Sep 17 00:00:00 2001 From: Dino Korah <691011+codemedic@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:34:06 +0100 Subject: [PATCH 1/2] Add GPT layout support, automated partitioning script, and test suite Closes #78. Fixes #156. glim-partition.sh (new): - Default mode: single FAT32 partition (MBR), universal compatibility - --gpt: GPT layout with BIOS Boot (ef02) + ESP (ef00, FAT32) + ext4 GLIM - --data-size SIZE: optional GLIMDATA partition (implies --gpt) - --data-fs exfat|ext4: GLIMDATA filesystem, defaults to exFAT (cross-platform, no file size limit); ext4 GLIMDATA is chmod 1777 for live-env write access - Zaps corrupted GPT headers in a separate pass before writing new layout - Refuses to run as root or touch the system disk glim.sh: - Detects a separate EFI System Partition via type GUID for EFI install; falls back to the GLIM partition on single-partition layouts - Uses lsblk -no PKNAME for parent device detection (handles NVMe correctly) - Chowns boot/ after grub-install so rsync works without sudo on ext4 mounts - Chowns the GLIM partition root after install so ISOs can be copied without sudo - Fixes #156: prompts now only accept y, Y, or Enter as yes; Escape and other non-n input no longer silently proceed with installation README.md: - Documents both partition layouts with automated and manual setup instructions - Secure Boot note: ISOs with unsigned shims (Gentoo, Arch, Artix) require Secure Boot to be disabled; this is not a GLIM bug tests/ (new): - pytest suite running against real loop devices; no mocking - Covers partition layout (MBR/GPT/exFAT/ext4), glim.sh install correctness, and QEMU BIOS/UEFI boot smoke tests - setup-sudo.sh installs a sudoers drop-in for passwordless test commands; --remove flag tears it down --- .gitignore | 6 + README.md | 92 ++++++++- glim-partition.sh | 392 ++++++++++++++++++++++++++++++++++++++ glim.sh | 75 ++++++-- pyproject.toml | 20 ++ tests/README.md | 188 ++++++++++++++++++ tests/conftest.py | 318 +++++++++++++++++++++++++++++++ tests/helpers/__init__.py | 0 tests/helpers/qemu.py | 162 ++++++++++++++++ tests/run.py | 140 ++++++++++++++ tests/setup-sudo.sh | 92 +++++++++ tests/test_boot.py | 164 ++++++++++++++++ tests/test_install.py | 109 +++++++++++ tests/test_partition.py | 225 ++++++++++++++++++++++ 14 files changed, 1958 insertions(+), 25 deletions(-) create mode 100644 .gitignore create mode 100755 glim-partition.sh create mode 100644 pyproject.toml create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/qemu.py create mode 100755 tests/run.py create mode 100644 tests/setup-sudo.sh create mode 100644 tests/test_boot.py create mode 100644 tests/test_install.py create mode 100644 tests/test_partition.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bd62ef83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Python / uv +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +uv.lock diff --git a/README.md b/README.md index 7571c683..47824a5c 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,17 @@ Disadvantages : * There is no persistence overlay for distributions which normally support it * Setting up isn't as easy as a simple cat from the ISO image to a block device -My experience has been that the safest filesystem to use is FAT32 -(surprisingly!), though it will mean that ISO images greater than 4GB won't be -supported. Other filesystems supported by GRUB2 also work, such as ext3/ext4, -NTFS and exFAT, but the boot of the distributions must also support it, which -isn't the case for many with NTFS (Ubuntu does, Fedora doesn't) and exFAT -(Ubuntu doesn't, Fedora does). So FAT32 stays the safe bet; make sure your device -is partitioned with MBR (not GPT) for legacy BIOS and EFI hybrid support for peak -compatibility. +Two partition layouts are supported: + +**Simple (default):** One FAT32 partition labelled `GLIM` holds both the GRUB +configuration and your ISO images. Readable on Windows and macOS, works across +BIOS and EFI systems. ISO images greater than 4 GB are not supported due to the +FAT32 file-size limit. + +**GPT (advanced, for ISOs > 4 GB):** A dedicated EFI System Partition and an +ext4 GLIM partition remove the 4 GB limit. An optional fourth partition +(`GLIMDATA`, exFAT by default) provides cross-platform storage accessible +from live environments. See [GPT Setup](#gpt-setup-for-isos--4-gb) below. Screenshots @@ -50,6 +53,79 @@ the filesystem label 'GLIM', mount it, clone this git repository and just run Once finished, you may change the filesystem label to anything you like. +**Automated simple setup** (creates a single FAT32 partition): + + ./glim-partition.sh /dev/sdX + +For ISOs larger than 4 GB, see [GPT Setup](#gpt-setup-for-isos--4-gb) first, +then run `./glim.sh` on the mounted GLIM partition as usual. + + +GPT Setup (for ISOs > 4 GB) +---------------------------- + +This layout supports ISO files of any size and optionally provides a data +partition accessible from live environments. + +**Partition layout:** + +| Partition | Size | Type | Label | Purpose | +|-----------|---------|------------------|------------|--------------------------------| +| P1 | 1 MiB | BIOS Boot (ef02) | (none) | GRUB2 BIOS core image | +| P2 | 256 MiB | EFI System (ef00)| (none) | EFI bootloader (FAT32) | +| P3 | [rest] | Linux (8300) | `GLIM` | GRUB config + ISO images (ext4)| +| P4 | [opt] | Linux (8300) | `GLIMDATA` | User storage (exFAT by default, optional) | + +GLIMDATA defaults to exFAT: readable on Windows, macOS, and Linux with no +file size limit. Use `--data-fs ext4` for a Linux-only journaled filesystem. + +**Automated setup** (destructive, erases the entire device): + + ./glim-partition.sh /dev/sdX --gpt # GLIM only + ./glim-partition.sh /dev/sdX --gpt --data-size 32G # with 32 GB data (exFAT) + ./glim-partition.sh /dev/sdX --gpt --data-size 32G --data-fs ext4 # ext4 data partition + +Required packages: `gdisk` (provides `sgdisk`), `dosfstools` (`mkfs.vfat`), +`e2fsprogs` (`mkfs.ext4`), `exfatprogs` (`mkfs.exfat`, for exFAT GLIMDATA). + +**Manual setup** (using `sgdisk` directly): + + # Partition + sudo sgdisk -Z \ + -n "1:2048:+1M" -t "1:ef02" -c "1:BIOS Boot" \ + -n "2:0:+256M" -t "2:ef00" -c "2:ESP" \ + -n "3:0:-32G" -t "3:8300" -c "3:GLIM" \ + -n "4:0:0" -t "4:8300" -c "4:GLIMDATA" \ + /dev/sdX + + # Omit the last two lines and use -n "3:0:0" if no data partition. + + # Format + sudo mkfs.vfat -F 32 /dev/sdX2 + sudo mkfs.ext4 -L GLIM /dev/sdX3 + sudo mkfs.exfat -n GLIMDATA /dev/sdX4 # optional, exFAT (cross-platform) + # or: sudo mkfs.ext4 -L GLIMDATA /dev/sdX4 # Linux-only + + # Mount and install GLIM + sudo mount /dev/sdX3 /mnt + ./glim.sh + sudo umount /mnt + +**Accessing the data partition from a live environment:** + +The `GLIMDATA` partition will appear as a standard block device. Most live +environments automount labelled partitions, or you can mount it manually: + + sudo mount /dev/disk/by-label/GLIMDATA /mnt/data + +**Secure Boot note:** + +GLIM itself does not require Secure Boot to be disabled. However, some ISO +images (Gentoo, Arch, Artix, and others) include shim bootloaders that are not +signed by a Microsoft-trusted CA. Booting these ISOs on a system with Secure +Boot enabled will produce a `bad shim signature` error. Disable Secure Boot in +your BIOS/UEFI firmware settings to boot unsigned ISOs. + The supported `boot/iso/` sub-directories (in alphabetical order) are : [//]: # (distro-list-start) diff --git a/glim-partition.sh b/glim-partition.sh new file mode 100755 index 00000000..2817f809 --- /dev/null +++ b/glim-partition.sh @@ -0,0 +1,392 @@ +#!/usr/bin/env bash +# +# GLIM USB Partitioning Script +# +# Default (simple) mode creates a single FAT32 partition — universal +# compatibility, works with all GRUB layouts: +# +# P1: FAT32 (label GLIM, full disk) +# +# With --gpt, creates a multi-partition GPT layout that supports ISOs +# larger than the FAT32 4 GiB file-size limit: +# +# P1: 1 MiB BIOS Boot Partition (type ef02) +# P2: 256 MiB EFI System Partition (type ef00, FAT32) +# P3: [rest] GLIM data partition (ext4, label GLIM) +# P4: [SIZE] User storage (exFAT by default, label GLIMDATA, optional) +# +# This script is destructive. All data on the target device will be lost. +# + +set -euo pipefail + +# Usage/help +# Output: prints usage text to stdout +usage() { + cat </dev/null; then + echo "ERROR: Required command not found: $cmd" + case "$cmd" in + sgdisk) echo " Install the 'gdisk' package." ;; + mkfs.vfat) echo " Install the 'dosfstools' package." ;; + mkfs.ext4) echo " Install the 'e2fsprogs' package." ;; + mkfs.exfat) echo " Install the 'exfatprogs' package." ;; + sfdisk) echo " Install the 'util-linux' package." ;; + esac + exit 1 + fi + done + + # Sanity check : block device + if [[ ! -b "$device" ]]; then + echo "ERROR: $device is not a block device." + exit 1 + fi + + # Sanity check : not the system disk + local root_part root_dev + root_part=$(findmnt -n -o SOURCE / 2>/dev/null || true) + if [[ -n "$root_part" ]]; then + root_dev="/dev/$(lsblk -no PKNAME "$root_part" 2>/dev/null || true)" + if [[ "$device" == "$root_dev" ]]; then + echo "ERROR: $device appears to be your system disk. Refusing to proceed." + exit 1 + fi + fi + + # Show current partition table + echo "Target device: $device" + echo "" + echo "Current partition table:" + lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT "$device" + echo "" + + # Layout summary + if [[ "$gpt" == true ]]; then + echo "This will create the following GPT layout on $device:" + echo " P1: 1 MiB BIOS Boot Partition (type ef02)" + echo " P2: 256 MiB EFI System Partition (type ef00, FAT32)" + echo " P3: [remaining${data_size:+ minus $data_size}] GLIM (ext4, label GLIM)" + if [[ -n "$data_size" ]]; then + echo " P4: $data_size Data ($data_fs, label GLIMDATA)" + fi + else + echo "This will create the following layout on $device:" + echo " P1: [full disk] GLIM (FAT32, label GLIM)" + fi + echo "" + echo "WARNING: ALL DATA ON $device WILL BE LOST!" + echo "" + local confirm + read -r -p "Type 'yes' to proceed: " confirm || true + if [[ "$confirm" != "yes" ]]; then + echo "Aborted." + exit 2 + fi + + echo "" + echo "Partitioning $device ..." + + # Determine partition device names. + # Devices ending in a digit (e.g. nvme0n1) use a 'p' separator: nvme0n1p1. + local part_prefix + if [[ "$device" =~ [0-9]$ ]]; then + part_prefix="${device}p" + else + part_prefix="${device}" + fi + + if [[ "$gpt" == true ]]; then + _partition_gpt "$device" "$part_prefix" "$data_size" "$data_fs" + else + _partition_simple "$device" "$part_prefix" + fi + + echo "" + echo "Done! Final layout:" + lsblk -o NAME,SIZE,FSTYPE,LABEL "$device" + echo "" + echo "Next steps:" + if [[ "$gpt" == true ]]; then + local glim="${part_prefix}3" + echo " 1. Mount the GLIM partition: sudo mount $glim /mnt" + echo " 2. Install GLIM: ./glim.sh" + echo " 3. Populate ISOs: /mnt/boot/iso//" + if [[ -n "$data_size" ]]; then + echo " 4. Data partition ready: ${part_prefix}4 (label GLIMDATA, $data_fs)" + fi + else + local glim="${part_prefix}1" + echo " 1. Mount the GLIM partition: sudo mount $glim /mnt" + echo " 2. Install GLIM: ./glim.sh" + echo " 3. Populate ISOs: /mnt/boot/iso//" + fi +} + +# _partition_simple DEVICE PART_PREFIX +# Create a single FAT32 primary partition covering the whole disk (MBR). +_partition_simple() { + local device="$1" + local part_prefix="$2" + local glim="${part_prefix}1" + + # Wipe any existing GPT/MBR signatures before writing the new layout. + # Ignoring exit code: sgdisk --zap-all may return non-zero if the + # existing table is already corrupt, but the wipe still succeeds. + sudo sgdisk --zap-all "$device" || true + sudo partprobe "$device" 2>/dev/null || true + sudo udevadm settle + + # Write an MBR partition table with one FAT32 primary partition. + # sfdisk format: ,, (type b = FAT32) + if ! echo "2048,,b" | sudo sfdisk "$device"; then + echo "ERROR: sfdisk failed." + exit 1 + fi + + sudo partprobe "$device" + sudo udevadm settle + + echo "" + echo "Formatting partition ..." + echo " GLIM (FAT32): $glim" + if ! sudo mkfs.vfat -F 32 -n GLIM "$glim"; then + echo "ERROR: Failed to format GLIM partition." + exit 1 + fi + + sudo udevadm settle +} + +# _partition_gpt DEVICE PART_PREFIX DATA_SIZE DATA_FS +# Create a GPT layout: BIOS Boot + ESP + ext4 GLIM [+ GLIMDATA in DATA_FS format]. +# DATA_FS is only used when DATA_SIZE is non-empty; accepted values: exfat, ext4. +_partition_gpt() { + local device="$1" + local part_prefix="$2" + local data_size="$3" + local data_fs="$4" + + local esp="${part_prefix}2" + local glim="${part_prefix}3" + local data="${part_prefix}4" + + # Zap any existing partition table first, in a separate pass. + # Combined -Z + partition args fail if the existing GPT is corrupted + # (e.g. valid backup but invalid main header) because sgdisk exits + # non-zero after printing warnings, before writing the new layout. + # Ignoring the exit code here is intentional — the zap succeeds even + # when sgdisk returns non-zero due to the pre-existing corruption. + sudo sgdisk --zap-all "$device" || true + sudo partprobe "$device" 2>/dev/null || true + sudo udevadm settle + + # Build sgdisk arguments: + # -n num:start:end New partition (0 = next free sector, +N = relative, -N = from end) + # -t num:type Partition type GUID shorthand + # -c num:name Partition name + local -a sgdisk_args=( + -n "1:2048:+1M" # P1: BIOS Boot (2048-sector aligned start) + -t "1:ef02" # P1 type: BIOS Boot Partition + -c "1:BIOS Boot" + -n "2:0:+256M" # P2: EFI System Partition + -t "2:ef00" + -c "2:ESP" + ) + + if [[ -n "$data_size" ]]; then + sgdisk_args+=( + -n "3:0:-${data_size}" # P3: GLIM (all remaining minus data partition) + -t "3:8300" + -c "3:GLIM" + -n "4:0:0" # P4: Data (fill the rest) + -t "4:8300" + -c "4:GLIMDATA" + ) + else + sgdisk_args+=( + -n "3:0:0" # P3: GLIM (fill all remaining space) + -t "3:8300" + -c "3:GLIM" + ) + fi + + if ! sudo sgdisk "${sgdisk_args[@]}" "$device"; then + echo "ERROR: sgdisk failed." + exit 1 + fi + + # Re-read partition table and wait for udev to settle + sudo partprobe "$device" + sudo udevadm settle + + echo "" + echo "Formatting partitions ..." + + echo " ESP (FAT32): $esp" + if ! sudo mkfs.vfat -F 32 "$esp"; then + echo "ERROR: Failed to format ESP." + exit 1 + fi + + echo " GLIM (ext4): $glim" + if ! sudo mkfs.ext4 -L GLIM "$glim"; then + echo "ERROR: Failed to format GLIM partition." + exit 1 + fi + + if [[ -n "$data_size" ]]; then + echo " GLIMDATA ($data_fs): $data" + case "$data_fs" in + exfat) + if ! sudo mkfs.exfat -n GLIMDATA "$data"; then + echo "ERROR: Failed to format data partition as exFAT." + exit 1 + fi + # exFAT does not support Unix permissions; the filesystem is + # accessible to all users by default when auto-mounted. + ;; + ext4) + if ! sudo mkfs.ext4 -L GLIMDATA "$data"; then + echo "ERROR: Failed to format data partition as ext4." + exit 1 + fi + # Make the filesystem root world-writable (sticky bit) so any user + # in any live environment can write to it regardless of UID/GID. + # 1777 matches the /tmp convention: anyone can create, only owners delete. + local tmp_mnt + tmp_mnt=$(mktemp -d) + # Trap ensures the mount is cleaned up even if chmod fails mid-way. + # shellcheck disable=SC2064 + trap "sudo umount '$tmp_mnt' 2>/dev/null || true; rmdir '$tmp_mnt' 2>/dev/null || true" EXIT + sudo mount "$data" "$tmp_mnt" + sudo chmod 1777 "$tmp_mnt" + sudo umount "$tmp_mnt" + rmdir "$tmp_mnt" + trap - EXIT + ;; + esac + fi + + sudo udevadm settle +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/glim.sh b/glim.sh index 65f5e5c7..23a72fb7 100755 --- a/glim.sh +++ b/glim.sh @@ -47,17 +47,13 @@ if [[ -z "$USBDEV1" ]]; then fi echo "Found partition with label 'GLIM' : ${USBDEV1}" -# Sanity check : our partition is the first and only one on the block device -USBDEV=${USBDEV1%1} +# Find the parent block device from the GLIM partition +USBDEV="/dev/$(lsblk -no PKNAME "$USBDEV1")" if [[ ! -b "$USBDEV" ]]; then echo "ERROR: ${USBDEV} block device not found." exit 1 fi echo "Found block device where to install GRUB2 : ${USBDEV}" -if [[ `ls -1 ${USBDEV}* | wc -l` -ne 2 ]]; then - echo "ERROR: ${USBDEV1} isn't the only partition on ${USBDEV}" - exit 1 -fi # Sanity check : our partition is mounted if ! grep -q -w ${USBDEV1} /proc/mounts; then @@ -88,12 +84,13 @@ fi if [[ $BIOS == true ]]; then # Set the target read -n 1 -s -p "Install for EFI in addition to standard BIOS? (Y/n) " EFI - if [[ "$EFI" == "n" ]]; then - EFI=false - echo "n" - else + # Accept only y, Y, or Enter (empty) as yes — anything else (n, Escape, …) is no. + if [[ "$EFI" =~ ^[yY]?$ ]]; then EFI=true echo "y" + else + EFI=false + echo "n" fi fi @@ -114,11 +111,12 @@ fi # Sanity check : human will read the info and confirm read -n 1 -s -p "Ready to install GLIM. Continue? (Y/n) " PROCEED -if [[ "$PROCEED" == "n" ]]; then +# Accept only y, Y, or Enter (empty) as yes — anything else (n, Escape, …) is no. +if [[ "$PROCEED" =~ ^[yY]?$ ]]; then + echo "y" +else echo "n" exit 2 -else - echo "y" fi # Install GRUB2 @@ -132,10 +130,39 @@ if [[ $BIOS == true ]]; then fi fi if [[ $EFI == true ]]; then - GRUB_TARGET="--target=x86_64-efi --efi-directory=${USBMNT} --removable" - echo "Running ${GRUB2_INSTALL} ${GRUB_TARGET} --boot-directory=${USBMNT}/boot ${USBDEV} (with sudo) ..." - sudo ${GRUB2_INSTALL} ${GRUB_TARGET} --boot-directory=${USBMNT}/boot ${USBDEV} - if [[ $? -ne 0 ]]; then + # Look for a separate EFI System Partition (type GUID c12a7328-...) on the same + # device. GPT layouts created by glim-partition.sh have a dedicated FAT32 ESP. + # On single-partition FAT32 sticks (legacy layout) no separate ESP exists and + # the GLIM partition doubles as the EFI directory. + ESP_GUID="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" + ESPDEV=$(lsblk -no PATH,PARTTYPE "$USBDEV" 2>/dev/null \ + | awk -v guid="$ESP_GUID" 'tolower($2) == guid {print $1; exit}') + ESPMNT="" + if [[ -n "$ESPDEV" && "$ESPDEV" != "$USBDEV1" ]]; then + echo "Found separate EFI System Partition: ${ESPDEV}" + ESPMNT=$(mktemp -d) + if ! sudo mount "$ESPDEV" "$ESPMNT"; then + echo "ERROR: Failed to mount ESP ${ESPDEV}." + rmdir "$ESPMNT" + exit 1 + fi + else + # Single-partition layout: GLIM partition serves as both EFI dir and ISO store + ESPMNT="$USBMNT" + fi + + GRUB_OPTS=(--target=x86_64-efi "--efi-directory=${ESPMNT}" --removable "--boot-directory=${USBMNT}/boot") + echo "Running ${GRUB2_INSTALL} ${GRUB_OPTS[*]} ${USBDEV} (with sudo) ..." + sudo "${GRUB2_INSTALL}" "${GRUB_OPTS[@]}" "${USBDEV}" + EFI_STATUS=$? + + # Unmount the ESP if we mounted it ourselves + if [[ -n "$ESPMNT" && "$ESPMNT" != "$USBMNT" ]]; then + sudo umount "$ESPMNT" + rmdir "$ESPMNT" + fi + + if [[ $EFI_STATUS -ne 0 ]]; then echo "ERROR: ${GRUB2_INSTALL} returned with an error exit status." exit 1 fi @@ -148,6 +175,13 @@ else CMD_PREFIX="sudo" fi +# grub-install (run as root) may have created boot/ owned by root even when +# the mount point is user-writable (e.g. ext4 GLIM partition). Chown it back +# so that rsync can write without requiring sudo. +if [[ -z "$CMD_PREFIX" ]] && [[ -d "${USBMNT}/boot" ]] && [[ ! -w "${USBMNT}/boot" ]]; then + sudo chown -R "$(id -u):$(id -g)" "${USBMNT}/boot" +fi + # Copy GRUB2 configuration echo "Running rsync -rt --delete --exclude=i386-pc --exclude=x86_64-efi --exclude=fonts ${GRUB2_CONF}/ ${USBMNT}/boot/${GRUB2_DIR} ..." ${CMD_PREFIX} rsync -rt --delete --exclude=i386-pc --exclude=x86_64-efi --exclude=fonts ${GRUB2_CONF}/ ${USBMNT}/boot/${GRUB2_DIR} @@ -170,3 +204,10 @@ for DIR in $(sed "${args[@]}" "$(dirname "$0")"/README.md); do [[ -d ${USBMNT}/boot/iso/${DIR} ]] || ${CMD_PREFIX} mkdir ${USBMNT}/boot/iso/${DIR} done +# Ensure the current user owns the GLIM partition so ISOs can be copied +# without sudo. grub-install runs as root and leaves files owned by root; +# this makes the stick usable immediately after installation. +if [[ ! -O "${USBMNT}" ]]; then + sudo chown -R "$(id -u):$(id -g)" "${USBMNT}" +fi + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..fe1cc296 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "glim-tests" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-timeout>=2.3", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "boot: tests that boot a QEMU VM (slow, require sudo + qemu-system-x86_64)", +] +timeout = 120 +# Disk images are too large for tmpfs — use the real filesystem +addopts = "--basetemp=/var/tmp/glim-tests" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..8e4c8b3e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,188 @@ +# GLIM Test Suite + +Automated tests for `glim-partition.sh` and `glim.sh`. Tests run +against real loop devices and (optionally) QEMU virtual machines — no +mocking of disk or filesystem operations. + +## Quick start + +```bash +# One-time setup: grant passwordless sudo for the required commands +sudo bash tests/setup-sudo.sh + +# Run partition + install tests (no QEMU required, ~3 min) +uv run tests/run.py --fast + +# Run QEMU boot tests only +uv run tests/run.py --boot + +# Run everything +uv run tests/run.py +``` + +## Prerequisites + +| Tool | Package | Required for | +|------|---------|-------------| +| `losetup`, `sfdisk` | `util-linux` | All tests | +| `sgdisk` | `gdisk` | GPT partition tests | +| `mkfs.vfat` | `dosfstools` | All tests | +| `mkfs.ext4` | `e2fsprogs` | GPT tests (GLIM partition + ext4 GLIMDATA) | +| `mkfs.exfat` | `exfatprogs` | GPT tests (exFAT GLIMDATA, default) | +| `udevadm`, `partprobe` | `udev`, `parted` | All tests | +| `qemu-system-x86_64` | `qemu-system-x86` | Boot tests | +| `OVMF_CODE_4M.fd` | `ovmf` | UEFI boot test | + +The sudoers drop-in (`tests/setup-sudo.sh`) grants passwordless `sudo` +for the specific commands above. Run it once after installing packages, +and re-run it whenever `setup-sudo.sh` itself changes (e.g. when new +commands are added). To revoke access when you no longer need it: + +```bash +sudo bash tests/setup-sudo.sh --remove +``` + +## How the tests work + +### Disk images and loop devices + +Every test that touches a disk creates a fresh blank image in a +temporary directory and attaches it as a loop device. No physical +disk is ever required. + +``` +tmp_path/glim-test.img (512 MiB) ─► /dev/loopX (GPT tests) +tmp_path/glim-test-2.img (512 MiB) ─► /dev/loopY (exFAT GLIMDATA tests) +tmp_path/glim-test-3.img (512 MiB) ─► /dev/loopZ (ext4 GLIMDATA tests) +tmp_path/glim-legacy-test.img (128 MiB) ─► /dev/loopW (FAT32 / simple tests) +``` + +Fixtures in `conftest.py` create and tear down loop devices +automatically. All temporary files land under +`/var/tmp/glim-tests/` (configured in `pyproject.toml`) rather than +`/tmp`, because disk images are too large for a tmpfs. + +### Fixture hierarchy + +``` +tmp_path +└── disk_image ──────────────────► loop_device + │ └── gpt_device ──────── mounted_glim + │ └── installed_glim + disk_image_2 ────────────────► loop_device_2 + │ └── gpt_device_with_data (exFAT, default) + │ + disk_image_3 ────────────────► loop_device_3 + │ └── gpt_device_with_data_ext4 (ext4, explicit) + │ + legacy_disk_image ───────────► legacy_loop_device + ├── simple_device (glim-partition.sh default mode) + ├── legacy_device (hand-crafted MBR via sfdisk) + └── mounted_legacy_glim + └── installed_legacy_glim +``` + +### Test files + +| File | What it tests | +|------|--------------| +| `test_partition.py` | `glim-partition.sh` output: partition count, types, filesystem labels, MBR vs GPT | +| `test_install.py` | `glim.sh` install: GRUB files, grub.cfg content, EFI binary in ESP, boot/iso dirs | +| `test_boot.py` | QEMU smoke test: GRUB prompt appears over serial console (BIOS + UEFI) | + +### Boot tests + +Boot tests (`@pytest.mark.boot`) launch `qemu-system-x86_64` with the +installed disk image and watch the serial output for `"GRUB"`. They +require QEMU and OVMF firmware but no network and no live ISO. + +The GRUB config is patched before booting to redirect output to the +serial console (`terminal_output serial console`) so QEMU's +`-nographic` mode captures it. + +Boot tests are excluded from `--fast` runs. Use `--boot` to run them +in isolation or omit the flag to run everything. + +## What each test class verifies + +### `TestSimpleLayout` — default FAT32 mode + +Verifies that `glim-partition.sh /dev/sdX` (no flags) produces: + +- Exactly one partition +- MBR (not GPT) partition table +- FAT32 filesystem with label `GLIM` + +### `TestGptLayout` — `--gpt` mode + +Verifies the three-partition GPT layout: + +- P1 BIOS Boot (type `ef02`) +- P2 EFI System Partition (type `ef00`, FAT32) +- P3 GLIM (Linux filesystem type, ext4, label `GLIM`) + +### `TestGptLayoutWithData` — `--gpt --data-size` (exFAT default) + +Verifies the four-partition layout with exFAT GLIMDATA (the default): + +- P4 formatted as exFAT with label `GLIMDATA` +- P3 (GLIM) is smaller than in the three-partition case (space was reserved for P4) +- P4 size is within 5% of the requested size + +### `TestGptLayoutWithDataExt4` — `--gpt --data-size --data-fs ext4` + +Verifies the four-partition layout with an explicit ext4 GLIMDATA: + +- P4 formatted as ext4 with label `GLIMDATA` + +### `TestSafetyChecks` + +- Abort on non-`yes` confirmation +- Reject a non-block-device path +- `--data-size` without `--gpt` auto-enables GPT +- `--data-fs` with an unsupported value (e.g. `ntfs`) is rejected before prompting + +### `TestGptInstall` / `TestLegacyInstall` + +Run `glim.sh` against a pre-partitioned loop device and check: + +- Non-zero exit code means failure +- `grub.cfg` is installed under `boot/grub/` or `boot/grub2/` +- MBR is not all-zero (BIOS GRUB embedded) +- `EFI/BOOT/BOOTX64.EFI` exists in the ESP +- `boot/iso/` and at least 10 distro subdirectories are created +- `boot/grub/` contains ≥ 30 `inc-*.cfg` files + +### `TestBiosBoot` / `TestUefiBoot` + +Boot the disk image in QEMU and assert `"GRUB"` appears within 45–60 +seconds on the serial console. + +## Adding tests + +1. Add fixtures to `conftest.py` if you need a new device configuration. +2. Add test classes/functions to the relevant `test_*.py` file. +3. Mark slow tests with `@pytest.mark.boot` if they require QEMU. +4. Run `uv run tests/run.py --fast` to validate before pushing. + +## Cleaning up after interrupted runs + +If a test run is interrupted, loop devices and mounts may be left +behind. Clean up with: + +```bash +grep '/var/tmp/glim-tests' /proc/mounts | awk '{print $2}' | sort -r | xargs -r sudo umount +sudo losetup -D +rm -rf /var/tmp/glim-tests +``` + +`tests/run.py` does this automatically at startup (unless active mounts +are detected, in which case it warns rather than wiping). + +Note: `glim-partition.sh` also creates a short-lived mount in `/tmp` when +formatting an ext4 GLIMDATA partition (to `chmod 1777` the root). If the +script is interrupted at that point, clean up with: + +```bash +grep 'loop.*p4' /proc/mounts | awk '{print $2}' | xargs -r sudo umount +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..29edc2d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,318 @@ +""" +Shared pytest fixtures for GLIM test suite. + +Scripts (glim-partition.sh, glim.sh) are run as the current user — they call +sudo internally for privileged operations. The sudoers drop-in installed by +``sudo bash tests/setup-sudo.sh`` grants passwordless access to those specific +commands. + +Fixtures that call privileged commands directly (losetup, mount, etc.) use the +sudo() helper which passes -n (non-interactive) so a missing sudoers entry +fails immediately with a clear error rather than hanging. +""" + +import os +import re +import subprocess +import pytest + +# Disk image size in MiB. +# Must accommodate: 1 MiB BIOS Boot + 256 MiB ESP + ext4 GLIM + GPT overhead. +# Minimum viable is ~360 MiB; 512 MiB gives a comfortable margin. +_DISK_SIZE_MIB = 512 + +# Minimum size for a legacy single-partition FAT32 image. +_LEGACY_DISK_SIZE_MIB = 128 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GRUB_PARTITION_SH = os.path.join(REPO_ROOT, "glim-partition.sh") +GLIM_SH = os.path.join(REPO_ROOT, "glim.sh") + + +def sudo(*args, input=None, check=True, capture_output=True): + """ + Run a command under sudo -n (non-interactive), returning CompletedProcess. + + -n causes sudo to fail immediately rather than prompting for a password. + Run ``sudo bash tests/setup-sudo.sh`` once before using the test suite. + """ + return subprocess.run( + ["sudo", "-n", *args], + input=input, + text=True, + check=check, + capture_output=capture_output, + ) + + +def run_script(script, *args, input=None, capture_output=True): + """ + Run a GLIM shell script as the current user (not via sudo). + + The scripts call sudo internally for privileged commands; those calls rely + on the sudoers drop-in for passwordless access. + """ + return subprocess.run( + ["bash", script, *args], + input=input, + text=True, + capture_output=capture_output, + ) + + +def part_name(device, num): + """ + Return the partition device name for *device* partition *num*. + Handles both /dev/sdXN and /dev/nvme0n1pN naming conventions. + """ + if re.search(r"\d$", device): + return f"{device}p{num}" + return f"{device}{num}" + + +# --------------------------------------------------------------------------- +# Disk image + loop device +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def disk_image(tmp_path): + """Create a blank disk image in a temporary directory.""" + img = tmp_path / "glim-test.img" + subprocess.run( + ["dd", "if=/dev/zero", f"of={img}", "bs=1M", f"count={_DISK_SIZE_MIB}"], + check=True, + capture_output=True, + ) + yield img + + +@pytest.fixture() +def legacy_disk_image(tmp_path): + """Smaller blank image for single-partition FAT32 tests.""" + img = tmp_path / "glim-legacy-test.img" + subprocess.run( + ["dd", "if=/dev/zero", f"of={img}", "bs=1M", f"count={_LEGACY_DISK_SIZE_MIB}"], + check=True, + capture_output=True, + ) + yield img + + +@pytest.fixture() +def loop_device(disk_image): + """Attach *disk_image* as a loop device with automatic partition scanning.""" + result = sudo("losetup", "--find", "--show", "-P", str(disk_image)) + device = result.stdout.strip() + assert device, "losetup did not return a device path" + yield device + sudo("losetup", "-d", device, check=False) + + +@pytest.fixture() +def disk_image_2(tmp_path): + """Second independent blank disk image (used when a test needs two GPT devices).""" + img = tmp_path / "glim-test-2.img" + subprocess.run( + ["dd", "if=/dev/zero", f"of={img}", "bs=1M", f"count={_DISK_SIZE_MIB}"], + check=True, + capture_output=True, + ) + yield img + + +@pytest.fixture() +def loop_device_2(disk_image_2): + """Second independent loop device backed by *disk_image_2*.""" + result = sudo("losetup", "--find", "--show", "-P", str(disk_image_2)) + device = result.stdout.strip() + assert device, "losetup did not return a device path" + yield device + sudo("losetup", "-d", device, check=False) + + +@pytest.fixture() +def legacy_loop_device(legacy_disk_image): + """Loop device for the single-partition FAT32 image.""" + result = sudo("losetup", "--find", "--show", "-P", str(legacy_disk_image)) + device = result.stdout.strip() + assert device, "losetup did not return a device path" + yield device + sudo("losetup", "-d", device, check=False) + + +# --------------------------------------------------------------------------- +# Partitioned + formatted devices +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def simple_device(legacy_loop_device): + """ + Loop device partitioned by glim-partition.sh in default (simple FAT32) mode. + Uses the legacy-sized (128 MiB) image since only one FAT32 partition is needed. + """ + result = run_script(GRUB_PARTITION_SH, legacy_loop_device, input="yes\n") + assert result.returncode == 0, ( + f"glim-partition.sh simple mode failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + sudo("udevadm", "settle") + yield legacy_loop_device + + +@pytest.fixture() +def gpt_device(loop_device): + """ + Loop device partitioned with glim-partition.sh (GPT, no data partition). + """ + result = run_script(GRUB_PARTITION_SH, loop_device, "--gpt", input="yes\n") + assert result.returncode == 0, ( + f"glim-partition.sh failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + sudo("udevadm", "settle") + yield loop_device + + +@pytest.fixture() +def gpt_device_with_data(loop_device_2): + """GPT layout including a 32 MiB GLIMDATA partition formatted as exFAT (default).""" + result = run_script(GRUB_PARTITION_SH, loop_device_2, "--gpt", "--data-size", "32M", input="yes\n") + assert result.returncode == 0, ( + f"glim-partition.sh failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + sudo("udevadm", "settle") + yield loop_device_2 + + +@pytest.fixture() +def disk_image_3(tmp_path): + """Third independent blank disk image (used for --data-fs ext4 tests).""" + img = tmp_path / "glim-test-3.img" + subprocess.run( + ["dd", "if=/dev/zero", f"of={img}", "bs=1M", f"count={_DISK_SIZE_MIB}"], + check=True, + capture_output=True, + ) + yield img + + +@pytest.fixture() +def loop_device_3(disk_image_3): + """Third independent loop device backed by *disk_image_3*.""" + result = sudo("losetup", "--find", "--show", "-P", str(disk_image_3)) + device = result.stdout.strip() + assert device, "losetup did not return a device path" + yield device + sudo("losetup", "-d", device, check=False) + + +@pytest.fixture() +def gpt_device_with_data_ext4(loop_device_3): + """GPT layout including a 32 MiB GLIMDATA partition formatted as ext4.""" + result = run_script( + GRUB_PARTITION_SH, loop_device_3, + "--gpt", "--data-size", "32M", "--data-fs", "ext4", + input="yes\n", + ) + assert result.returncode == 0, ( + f"glim-partition.sh failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + sudo("udevadm", "settle") + yield loop_device_3 + + +@pytest.fixture() +def legacy_device(legacy_loop_device): + """ + Single-partition FAT32 device simulating the legacy GLIM layout. + + Uses an MBR partition table with one FAT32 partition — exactly what a + real legacy GLIM USB stick looks like. MBR allows GRUB BIOS to install + its core image into the MBR gap (no BIOS Boot partition required). + """ + glim_part = part_name(legacy_loop_device, 1) + # Write an MBR partition table with a single FAT32 primary partition. + # sfdisk accepts: ,, where type b = FAT32 + sudo("sfdisk", legacy_loop_device, input="2048,,b\n") + sudo("partprobe", legacy_loop_device, check=False) + sudo("udevadm", "settle") + sudo("mkfs.vfat", "-F", "32", "-n", "GLIM", glim_part) + sudo("udevadm", "settle") + yield legacy_loop_device + + +# --------------------------------------------------------------------------- +# Mounted GLIM partition +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mounted_glim(gpt_device, tmp_path): + """ + Mount the ext4 GLIM partition (P3) of a GPT device. + Chowns the mount point so glim.sh can write without needing sudo for rsync. + Yields the mount-point Path. + """ + glim_part = part_name(gpt_device, 3) + mount_point = tmp_path / "glim" + mount_point.mkdir() + sudo("mount", glim_part, str(mount_point)) + sudo("chown", "-R", f"{os.getuid()}:{os.getgid()}", str(mount_point)) + yield mount_point + sudo("umount", str(mount_point), check=False) + + +@pytest.fixture() +def mounted_legacy_glim(legacy_device, tmp_path): + """Mount the FAT32 partition of the legacy single-partition device.""" + glim_part = part_name(legacy_device, 1) + mount_point = tmp_path / "glim-legacy" + mount_point.mkdir() + # Mount with uid/gid so all FAT32 files appear owned by the current user. + # This lets glim.sh run rsync without sudo (same as a user-mounted USB stick). + sudo("mount", "-o", f"uid={os.getuid()},gid={os.getgid()}", + glim_part, str(mount_point)) + yield mount_point + sudo("umount", str(mount_point), check=False) + + +# --------------------------------------------------------------------------- +# Installed GLIM (glim.sh has been run) +# --------------------------------------------------------------------------- + + +def _run_glim_sh(answers=b"yy"): + """ + Run glim.sh as the current user, sending *answers* to interactive prompts. + + glim.sh prompts (in order, when both BIOS + EFI GRUB are installed): + 1. "Install for EFI in addition to standard BIOS? (Y/n)" → 'y' + 2. "Ready to install GLIM. Continue? (Y/n)" → 'y' + """ + return subprocess.run( + ["bash", GLIM_SH], + input=answers, + capture_output=True, + ) + + +@pytest.fixture() +def installed_glim(gpt_device, mounted_glim): + """GPT device with GRUB installed via glim.sh (BIOS + EFI).""" + result = _run_glim_sh() + assert result.returncode == 0, ( + f"glim.sh failed:\nSTDOUT:\n{result.stdout.decode()}\n" + f"STDERR:\n{result.stderr.decode()}" + ) + yield gpt_device, mounted_glim + + +@pytest.fixture() +def installed_legacy_glim(legacy_device, mounted_legacy_glim): + """Legacy FAT32 device with GRUB installed via glim.sh.""" + result = _run_glim_sh() + assert result.returncode == 0, ( + f"glim.sh failed:\nSTDOUT:\n{result.stdout.decode()}\n" + f"STDERR:\n{result.stderr.decode()}" + ) + yield legacy_device, mounted_legacy_glim diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/helpers/qemu.py b/tests/helpers/qemu.py new file mode 100644 index 00000000..73a6d3d5 --- /dev/null +++ b/tests/helpers/qemu.py @@ -0,0 +1,162 @@ +""" +QEMU boot helper for GLIM tests. + +Boots a disk image headlessly with serial console output, waits for a set of +expected strings to appear (or a timeout), then returns the collected output. + +Usage:: + + from helpers.qemu import QemuBoot + + with QemuBoot.bios(disk_image_path) as q: + output = q.wait_for("GLIM", timeout=30) + assert "Ubuntu" in output +""" + +import os +import re +import select +import signal +import subprocess +import time + +# OVMF firmware for UEFI testing +_OVMF_CANDIDATES = [ + "/usr/share/OVMF/OVMF_CODE_4M.fd", + "/usr/share/OVMF/OVMF_CODE.fd", + "/usr/share/ovmf/OVMF.fd", + "/usr/share/qemu/OVMF.fd", + "/usr/share/edk2/ovmf/OVMF_CODE.fd", +] + +_QEMU = "qemu-system-x86_64" + +# How long (seconds) to wait for expected strings before giving up +_DEFAULT_TIMEOUT = 45 + + +def _find_ovmf(): + for path in _OVMF_CANDIDATES: + if os.path.isfile(path): + return path + raise FileNotFoundError( + "OVMF firmware not found. Install the 'ovmf' package.\n" + f"Searched: {_OVMF_CANDIDATES}" + ) + + +class QemuBoot: + """ + Context manager that boots a QEMU instance and captures its serial output. + + Do not instantiate directly — use the :meth:`bios` or :meth:`uefi` class + methods. + """ + + def __init__(self, cmd): + self._cmd = cmd + self._proc = None + self._output = "" + + # ------------------------------------------------------------------ + # Factory methods + # ------------------------------------------------------------------ + + @classmethod + def bios(cls, disk_image): + """Boot *disk_image* in legacy BIOS mode.""" + cmd = [ + _QEMU, + "-drive", f"file={disk_image},format=raw,if=ide", + "-m", "512", + # -nographic disables VGA and redirects the first serial port to + # stdin/stdout; no separate -serial flag needed (would conflict). + "-nographic", + "-no-reboot", + "-boot", "c", + ] + return cls(cmd) + + @classmethod + def uefi(cls, disk_image): + """Boot *disk_image* in UEFI mode using OVMF.""" + ovmf = _find_ovmf() + cmd = [ + _QEMU, + "-drive", f"if=pflash,format=raw,readonly=on,file={ovmf}", + "-drive", f"file={disk_image},format=raw,if=ide", + "-m", "512", + "-nographic", + "-no-reboot", + ] + return cls(cmd) + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self): + self._proc = subprocess.Popen( + self._cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + ) + return self + + def __exit__(self, *_): + if self._proc and self._proc.poll() is None: + self._proc.send_signal(signal.SIGTERM) + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + + # ------------------------------------------------------------------ + # Output collection + # ------------------------------------------------------------------ + + def wait_for(self, *expected, timeout=_DEFAULT_TIMEOUT): + """ + Read serial output until *all* strings in *expected* have appeared, + or *timeout* seconds have elapsed. + + Returns the full collected output string. + Raises TimeoutError if the deadline is reached before all strings appear. + """ + deadline = time.monotonic() + timeout + found = set() + needed = set(expected) + + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + ready, _, _ = select.select([self._proc.stdout], [], [], min(remaining, 0.5)) + if ready: + chunk = self._proc.stdout.read(4096) + if not chunk: + break # EOF — QEMU exited + text = chunk.decode("utf-8", errors="replace") + self._output += text + for s in needed - found: + if s in self._output: + found.add(s) + if found == needed: + return self._output + + if self._proc.poll() is not None: + break # QEMU exited unexpectedly + + missing = needed - found + raise TimeoutError( + f"Timed out after {timeout}s waiting for: {missing!r}\n" + f"--- Collected output ---\n{self._output}" + ) + + @property + def output(self): + """Return all output collected so far.""" + return self._output diff --git a/tests/run.py b/tests/run.py new file mode 100755 index 00000000..d6d2c841 --- /dev/null +++ b/tests/run.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +GLIM test runner. + +Dependencies are managed via pyproject.toml. Run with: + + uv run tests/run.py # all tests + uv run tests/run.py --fast # partition + install only (no QEMU) + uv run tests/run.py --boot # QEMU boot tests only + uv run tests/run.py -- -v -k test_p3_label_is_glim + +Prerequisites (all tests) +------------------------- +- passwordless sudo for: losetup, sgdisk, mkfs.ext4, mkfs.exfat, mkfs.vfat, + mount, umount, partprobe, udevadm, sync, dd, chown, chmod +- gdisk (sgdisk), dosfstools (mkfs.vfat), e2fsprogs (mkfs.ext4), + exfatprogs (mkfs.exfat) + +Additional prerequisites (boot tests) +-------------------------------------- +- qemu-system-x86_64 +- OVMF firmware package (ovmf on Debian/Ubuntu) +""" + +import json +import os +import pathlib +import shutil +import subprocess +import sys +import argparse + +# Make the tests/ directory importable when invoked as a script +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _TESTS_DIR not in sys.path: + sys.path.insert(0, _TESTS_DIR) + +import pytest + +# Our exclusive basetemp directory — safe to wipe on startup. +_BASETEMP = pathlib.Path("/var/tmp/glim-tests") + + +def _sudo(*args): + """Run a command under sudo -n (non-interactive). Returns True on success.""" + return subprocess.run( + ["sudo", "-n", *args], + capture_output=True, + ).returncode == 0 + + +def _force_cleanup(path: pathlib.Path): + """ + Tear down any mounts and loop devices under *path*, then delete it. + + Safe to call on a clean directory — all steps are best-effort. + """ + if not path.exists(): + return + + # 1. Unmount everything under this path (reverse order so nested mounts go first). + with open("/proc/mounts") as f: + mounts = [ + line.split()[1] + for line in f + if line.split()[1].startswith(str(path)) + ] + for mount_point in sorted(mounts, reverse=True): + _sudo("umount", "-l", mount_point) + + # 2. Detach loop devices whose backing file lives under *path*. + # We filter by back-file so we never touch loop devices belonging + # to other processes or mounted system images. + result = subprocess.run( + ["sudo", "-n", "losetup", "--json"], + capture_output=True, text=True, + ) + if result.returncode == 0 and result.stdout.strip(): + try: + data = json.loads(result.stdout) + for dev in data.get("loopdevices", []): + backing = dev.get("back-file", "") + if backing.startswith(str(path)): + _sudo("losetup", "-d", dev["name"]) + except (json.JSONDecodeError, KeyError): + pass + + # 3. Delete the directory tree. + shutil.rmtree(path, ignore_errors=True) + + if path.exists(): + print(f"WARNING: Could not fully remove {path} — some files may need manual cleanup.") + + +def _clean_stale_dirs(): + """Remove stale basetemp left behind by aborted runs.""" + if _BASETEMP.exists(): + print(f"Cleaning up stale temp dir: {_BASETEMP}") + _force_cleanup(_BASETEMP) + + +def main(): + parser = argparse.ArgumentParser(description="GLIM automated test runner") + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--fast", + action="store_true", + help="Partition + install tests only (no QEMU)", + ) + mode.add_argument( + "--boot", + action="store_true", + help="QEMU boot tests only", + ) + parser.add_argument( + "extra", + nargs=argparse.REMAINDER, + help="Extra arguments forwarded to pytest (after --)", + ) + args = parser.parse_args() + + _clean_stale_dirs() + + pytest_args = [_TESTS_DIR, "-v"] + + if args.fast: + pytest_args += ["-m", "not boot"] + elif args.boot: + pytest_args += ["-m", "boot"] + + extra = args.extra + if extra and extra[0] == "--": + extra = extra[1:] + pytest_args += extra + + sys.exit(pytest.main(pytest_args)) + + +if __name__ == "__main__": + main() diff --git a/tests/setup-sudo.sh b/tests/setup-sudo.sh new file mode 100644 index 00000000..c19fb71e --- /dev/null +++ b/tests/setup-sudo.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Install a sudoers drop-in that grants the current user passwordless access +# to the specific commands needed by the GLIM test suite. +# +# Setup: sudo bash tests/setup-sudo.sh +# Teardown: sudo bash tests/setup-sudo.sh --remove +# + +set -euo pipefail + +SUDOERS_FILE="/etc/sudoers.d/glim-tests" + +if [[ $(id -u) -ne 0 ]]; then + echo "ERROR: Run this script with sudo: sudo bash $0" + exit 1 +fi + +if [[ "${1:-}" == "--remove" ]]; then + if [[ -f "$SUDOERS_FILE" ]]; then + rm "$SUDOERS_FILE" + echo "Removed: $SUDOERS_FILE" + else + echo "Nothing to remove: $SUDOERS_FILE does not exist." + fi + exit 0 +fi + +CURRENT_USER="${SUDO_USER:-$(logname)}" + +# Commands required by the test suite +CMDS=( + /usr/sbin/losetup + /usr/sbin/partprobe + /usr/sbin/sgdisk + /sbin/sfdisk + /usr/sbin/mkfs.ext4 + /usr/sbin/mkfs.exfat + /usr/sbin/mkfs.vfat + /usr/bin/mount + /usr/bin/umount + /usr/sbin/udevadm + /usr/bin/sync + /bin/dd + /usr/bin/chown + /usr/bin/chmod + /usr/bin/lsblk + /usr/bin/blkid + /usr/sbin/grub-install + /usr/sbin/grub2-install +) + +# Build comma-separated command list, only including paths that exist +ALLOWED="" +for cmd in "${CMDS[@]}"; do + # Also check without the full path prefix (distro differences) + actual=$(command -v "$(basename "$cmd")" 2>/dev/null || true) + if [[ -n "$actual" ]]; then + if [[ -n "$ALLOWED" ]]; then + ALLOWED="${ALLOWED}, ${actual}" + else + ALLOWED="${actual}" + fi + fi +done + +if [[ -z "$ALLOWED" ]]; then + echo "ERROR: No matching commands found. Is the test environment set up?" + exit 1 +fi + +cat > "$SUDOERS_FILE" <= 10, f"Expected ≥10 distro dirs, found {len(dirs)}: {dirs}" + + def test_grub_cfg_references_isopath(self, installed_glim): + _, mount_point = installed_glim + grub_dir = _grub_dir(mount_point) + cfg = (grub_dir / "grub.cfg").read_text() + assert "isopath" in cfg + assert "/boot/iso" in cfg + + def test_inc_cfgs_installed(self, installed_glim): + _, mount_point = installed_glim + grub_dir = _grub_dir(mount_point) + inc_files = list(grub_dir.glob("inc-*.cfg")) + assert len(inc_files) >= 30, ( + f"Expected ≥30 inc-*.cfg files, found {len(inc_files)}" + ) + + +# --------------------------------------------------------------------------- +# Legacy FAT32 install (backwards compatibility) +# --------------------------------------------------------------------------- + + +class TestLegacyInstall: + def test_glim_sh_succeeds_on_legacy(self, installed_legacy_glim): + pass + + def test_grub_cfg_installed_on_legacy(self, installed_legacy_glim): + _, mount_point = installed_legacy_glim + grub_dir = _grub_dir(mount_point) + assert grub_dir is not None + assert (grub_dir / "grub.cfg").is_file() + + def test_boot_iso_directories_on_legacy(self, installed_legacy_glim): + _, mount_point = installed_legacy_glim + iso_root = mount_point / "boot" / "iso" + assert iso_root.is_dir() diff --git a/tests/test_partition.py b/tests/test_partition.py new file mode 100644 index 00000000..6aa797b1 --- /dev/null +++ b/tests/test_partition.py @@ -0,0 +1,225 @@ +""" +Tests for glim-partition.sh. + +Verifies that the GPT layout produced by the script matches the expected +partition types, labels, and relative sizes — without booting anything. +""" + +import json +import subprocess +import pytest + +from conftest import sudo, part_name + +# EFI System Partition type GUID (lowercase, as reported by lsblk) +_ESP_GUID = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" +# BIOS Boot Partition type GUID +_BIOS_BOOT_GUID = "21686148-6449-6e6f-744e-656564454649" +# Linux filesystem type GUID +_LINUX_FS_GUID = "0fc63daf-8483-4772-8e79-3d69d8477de4" + + +def _lsblk(device): + """ + Return lsblk JSON output for *device* as a list of partition dicts. + Each dict has keys: name, parttype, fstype, label, size. + """ + result = sudo( + "lsblk", "--json", "--output", + "NAME,PARTTYPE,FSTYPE,LABEL,SIZE,PARTLABEL", + device, + ) + data = json.loads(result.stdout) + # Top-level entry is the device; children are the partitions + children = data["blockdevices"][0].get("children", []) + return children + + +# --------------------------------------------------------------------------- +# Simple default layout (single FAT32 partition, MBR) +# --------------------------------------------------------------------------- + + +class TestSimpleLayout: + def test_single_partition_created(self, simple_device): + parts = _lsblk(simple_device) + assert len(parts) == 1, f"Expected 1 partition, got {len(parts)}: {parts}" + + def test_p1_formatted_fat32(self, simple_device): + parts = _lsblk(simple_device) + assert parts[0]["fstype"] == "vfat" + + def test_p1_label_is_glim(self, simple_device): + parts = _lsblk(simple_device) + assert parts[0]["label"] == "GLIM" + + def test_partition_table_is_mbr(self, simple_device): + """Partition table must be MBR (dos), not GPT.""" + result = sudo("blkid", "-o", "value", "-s", "PTTYPE", simple_device) + assert result.stdout.strip() == "dos", ( + f"Expected MBR (dos) partition table, got: {result.stdout.strip()!r}" + ) + + +# --------------------------------------------------------------------------- +# Basic GPT layout (no data partition) +# --------------------------------------------------------------------------- + + +class TestGptLayout: + def test_four_partitions_not_created_without_data_flag(self, gpt_device): + parts = _lsblk(gpt_device) + # P1 BIOS Boot, P2 ESP, P3 GLIM — exactly 3 + assert len(parts) == 3, f"Expected 3 partitions, got {len(parts)}: {parts}" + + def test_p1_bios_boot_type(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[0]["parttype"].lower() == _BIOS_BOOT_GUID + + def test_p2_efi_system_partition_type(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[1]["parttype"].lower() == _ESP_GUID + + def test_p2_formatted_fat32(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[1]["fstype"] == "vfat" + + def test_p3_glim_type(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[2]["parttype"].lower() == _LINUX_FS_GUID + + def test_p3_formatted_ext4(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[2]["fstype"] == "ext4" + + def test_p3_label_is_glim(self, gpt_device): + parts = _lsblk(gpt_device) + assert parts[2]["label"] == "GLIM" + + +# --------------------------------------------------------------------------- +# GPT layout with optional data partition +# --------------------------------------------------------------------------- + + +class TestGptLayoutWithData: + def test_four_partitions_created(self, gpt_device_with_data): + parts = _lsblk(gpt_device_with_data) + assert len(parts) == 4, f"Expected 4 partitions, got {len(parts)}: {parts}" + + def test_p4_formatted_exfat(self, gpt_device_with_data): + """Default --data-fs is exFAT.""" + parts = _lsblk(gpt_device_with_data) + assert parts[3]["fstype"] == "exfat" + + def test_p4_label_is_glimdata(self, gpt_device_with_data): + parts = _lsblk(gpt_device_with_data) + assert parts[3]["label"] == "GLIMDATA" + + def test_p3_is_smaller_than_without_data(self, gpt_device, gpt_device_with_data): + """P3 (GLIM) must be smaller when a data partition is present.""" + result_no_data = sudo("sgdisk", "--print", gpt_device) + result_with_data = sudo("sgdisk", "--print", gpt_device_with_data) + + def p3_sectors(sgdisk_output): + for line in sgdisk_output.splitlines(): + if line.strip().startswith("3 "): + cols = line.split() + return int(cols[2]) - int(cols[1]) + return None + + sectors_no_data = p3_sectors(result_no_data.stdout) + sectors_with_data = p3_sectors(result_with_data.stdout) + assert sectors_no_data is not None + assert sectors_with_data is not None + assert sectors_with_data < sectors_no_data + + def test_p4_size_matches_requested(self, gpt_device_with_data): + """P4 (GLIMDATA) sector count must be within 5% of the requested 32 MiB.""" + result = sudo("sgdisk", "--print", gpt_device_with_data) + + p4_sectors = None + sector_size = 512 # default; overridden below if sgdisk reports otherwise + for line in result.stdout.splitlines(): + # "Logical sector size: 512 bytes" + if "logical sector size" in line.lower(): + sector_size = int(line.split()[-2]) + if line.strip().startswith("4 "): + cols = line.split() + p4_sectors = int(cols[2]) - int(cols[1]) + + assert p4_sectors is not None, "P4 not found in sgdisk output" + + requested_bytes = 32 * 1024 * 1024 # 32 MiB passed as --data-size to the fixture + actual_bytes = p4_sectors * sector_size + # Allow up to 5% deviation for partition alignment rounding + assert abs(actual_bytes - requested_bytes) / requested_bytes < 0.05, ( + f"P4 size {actual_bytes} bytes deviates >5% from requested {requested_bytes} bytes" + ) + + +# --------------------------------------------------------------------------- +# Safety checks +# --------------------------------------------------------------------------- + + +class TestSafetyChecks: + def test_aborts_on_non_yes_confirmation(self, loop_device): + """Script must not partition the device if user types anything other than 'yes'.""" + from conftest import run_script, GRUB_PARTITION_SH + result = run_script(GRUB_PARTITION_SH, loop_device, input="no\n") + assert result.returncode != 0 + + # Verify nothing was written (blkid sees no known partition table) + check = sudo("blkid", "-o", "value", "-s", "PTTYPE", loop_device, check=False) + assert check.stdout.strip() == "" + + def test_rejects_non_block_device(self, tmp_path): + fake = tmp_path / "not-a-device" + fake.write_text("") + result = subprocess.run( + ["bash", __import__("conftest").GRUB_PARTITION_SH, str(fake)], + capture_output=True, text=True, + ) + assert result.returncode != 0 + assert "not a block device" in result.stderr.lower() or \ + "not a block device" in result.stdout.lower() + + def test_data_size_without_gpt_enables_gpt(self, loop_device): + """--data-size without --gpt should auto-enable GPT and succeed.""" + from conftest import run_script, GRUB_PARTITION_SH + result = run_script(GRUB_PARTITION_SH, loop_device, "--data-size", "32M", input="yes\n") + assert result.returncode == 0 + assert "implies --gpt" in result.stdout or "enabling gpt" in result.stdout.lower() + # Verify GPT was created + parts = _lsblk(loop_device) + assert len(parts) == 4 + + def test_invalid_data_fs_rejected(self, tmp_path): + """--data-fs with an unsupported value must exit non-zero before prompting.""" + import conftest + fake = tmp_path / "not-a-device" + fake.write_text("") + result = conftest.run_script( + conftest.GRUB_PARTITION_SH, + "/dev/null", + "--data-fs", "ntfs", + ) + assert result.returncode != 0 + assert "exfat" in result.stdout.lower() or "exfat" in result.stderr.lower() + + +# --------------------------------------------------------------------------- +# GPT layout with ext4 data partition (--data-fs ext4) +# --------------------------------------------------------------------------- + + +class TestGptLayoutWithDataExt4: + def test_p4_formatted_ext4(self, gpt_device_with_data_ext4): + parts = _lsblk(gpt_device_with_data_ext4) + assert len(parts) == 4 + assert parts[3]["fstype"] == "ext4" + + def test_p4_label_is_glimdata(self, gpt_device_with_data_ext4): + parts = _lsblk(gpt_device_with_data_ext4) + assert parts[3]["label"] == "GLIMDATA" From 56992dd98e18f40d10f077bdd5ef0050e7d1835f Mon Sep 17 00:00:00 2001 From: Dino Korah <691011+codemedic@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:44:45 +0100 Subject: [PATCH 2/2] Fix README.md code comments alignment --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47824a5c..5490a079 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ file size limit. Use `--data-fs ext4` for a Linux-only journaled filesystem. **Automated setup** (destructive, erases the entire device): - ./glim-partition.sh /dev/sdX --gpt # GLIM only - ./glim-partition.sh /dev/sdX --gpt --data-size 32G # with 32 GB data (exFAT) + ./glim-partition.sh /dev/sdX --gpt # GLIM only + ./glim-partition.sh /dev/sdX --gpt --data-size 32G # with 32 GB data (exFAT) ./glim-partition.sh /dev/sdX --gpt --data-size 32G --data-fs ext4 # ext4 data partition Required packages: `gdisk` (provides `sgdisk`), `dosfstools` (`mkfs.vfat`),