diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bed359b..e5a66ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - run: cargo build --release --workspace qemu-uefi-smoke: - name: Read-only UEFI smoke assertion + name: Bootable write → virtual USB → UEFI runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -69,9 +69,16 @@ jobs: BOOTABLE_QEMU_WAIT_SECONDS: 10 BOOTABLE_QEMU_COLOR_TOLERANCE: 64 run: scripts/qemu-uefi-smoke.sh --cdrom /tmp/bootable-uefi-fixture.iso /tmp/bootable-uefi-fixture.png 0000aa + - name: Write, verify, and boot a disposable virtual USB + env: + BOOTABLE_QEMU_WAIT_SECONDS: 10 + BOOTABLE_QEMU_COLOR_TOLERANCE: 64 + run: scripts/qemu-usb-write-uefi-smoke.sh /tmp/bootable-uefi-fixture.iso /tmp/bootable-qemu-usb.png 0000aa - uses: actions/upload-artifact@v4 if: always() with: name: qemu-uefi-smoke-frame - path: /tmp/bootable-uefi-fixture.png + path: | + /tmp/bootable-uefi-fixture.png + /tmp/bootable-qemu-usb.png if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index f60fe3b..7be7eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "bootable-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bzip2", "flate2", @@ -817,7 +817,7 @@ dependencies = [ [[package]] name = "bootable-desktop" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "bootable-core", @@ -831,7 +831,7 @@ dependencies = [ [[package]] name = "bootable-helper" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bootable-core", "serde_json", @@ -839,7 +839,7 @@ dependencies = [ [[package]] name = "bootable-tui" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "bootable-core", diff --git a/Cargo.toml b/Cargo.toml index 2cbb94f..1276bc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ default-members = [ resolver = "2" [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "Apache-2.0" rust-version = "1.88" diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index ec5d32e..f02488d 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -128,6 +128,9 @@ enum Commands { target: String, #[arg(long, value_name = "EXACT_PHRASE")] confirm: Option, + /// Emit newline-delimited JSON progress events for trusted clients. + #[arg(long)] + json_progress: bool, #[command(flatten)] windows: WindowsArgs, #[arg(long, default_value = "off", value_name = "off|1|2|4")] @@ -207,6 +210,7 @@ fn main() -> Result<()> { image, target, confirm, + json_progress, windows, bad_block_check, }) => write_image( @@ -214,6 +218,7 @@ fn main() -> Result<()> { image, &target, confirm, + json_progress, write_options(windows, bad_block_check), ), None if io::stdout().is_terminal() => run_tui(engine, cli.image), @@ -451,19 +456,30 @@ fn write_image( image: PathBuf, target: &str, confirmation: Option, + json_progress: bool, options: WriteOptions, ) -> Result<()> { let plan = engine.prepare_with_options(image, target, options)?; let Some(confirmation) = confirmation else { render_plan_text(&plan); bail!( - "nothing was written; repeat with --confirm '{}' as root/admin", + "nothing was written; repeat with --confirm '{}'", plan.confirmation_phrase ); }; - let mut reporter = ProgressReporter::default(); - engine.write(&plan, &confirmation, |progress| reporter.print(progress))?; - Ok(()) + let mut reporter = ProgressReporter::new(json_progress); + let result = + engine.write_with_privilege(&plan, &confirmation, |progress| reporter.print(progress)); + match result { + Ok(()) => { + reporter.finished(); + Ok(()) + } + Err(error) => { + reporter.failed(&error.to_string()); + Err(error.into()) + } + } } fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteOptions { @@ -491,9 +507,18 @@ fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteO struct ProgressReporter { phase: Option, percentage: Option, + json: bool, } impl ProgressReporter { + fn new(json: bool) -> Self { + Self { + phase: None, + percentage: None, + json, + } + } + fn print(&mut self, progress: Progress) { let percentage = progress .total @@ -504,6 +529,13 @@ impl ProgressReporter { if !phase_changed && !percentage_changed { return; } + if self.json { + println!("{}", progress_event_json(&progress)); + let _ = io::Write::flush(&mut io::stdout()); + self.phase = Some(progress.phase); + self.percentage = percentage; + return; + } let amount = percentage .map(|value| format!("{value:>3}%")) .unwrap_or_else(|| "...".into()); @@ -511,6 +543,25 @@ impl ProgressReporter { self.phase = Some(progress.phase); self.percentage = percentage; } + + fn finished(&self) { + if self.json { + println!("{{\"event\":\"finished\"}}"); + } + } + + fn failed(&self, message: &str) { + if self.json { + println!( + "{}", + serde_json::json!({ "event": "failed", "data": { "message": message } }) + ); + } + } +} + +fn progress_event_json(progress: &Progress) -> String { + serde_json::json!({ "event": "progress", "data": progress }).to_string() } fn render_plan_text(plan: &WritePlan) { @@ -4911,9 +4962,11 @@ fn device_change_message(added: usize, removed: usize) -> String { #[cfg(test)] mod layout_tests { use super::{ - WorkspaceFocus, advanced_height, application_area, brand_lockup, centered_button_area, - grid_areas, main_shell_layout, windows_option_columns, workspace_height, + Cli, Commands, Progress, ProgressPhase, WorkspaceFocus, advanced_height, application_area, + brand_lockup, centered_button_area, grid_areas, main_shell_layout, progress_event_json, + windows_option_columns, workspace_height, }; + use clap::Parser; use ratatui::layout::Rect; #[test] @@ -4996,4 +5049,40 @@ mod layout_tests { assert!(lines[0].to_string().contains("┌┬┬┐ BOOTABLE α")); assert!(lines[1].to_string().contains("╰♨─╯")); } + + #[test] + fn write_json_progress_is_an_explicit_client_mode() { + let cli = Cli::try_parse_from([ + "bootable", + "write", + "image.iso", + "/dev/removable", + "--confirm", + "ERASE /dev/removable TEST", + "--json-progress", + ]) + .expect("valid client invocation"); + assert!(matches!( + cli.command, + Some(Commands::Write { + json_progress: true, + .. + }) + )); + } + + #[test] + fn progress_events_are_stable_newline_json_payloads() { + let event = progress_event_json(&Progress { + phase: ProgressPhase::Writing, + completed: 25, + total: Some(100), + message: "Writing and verifying".into(), + }); + let value: serde_json::Value = serde_json::from_str(&event).expect("valid JSON"); + assert_eq!(value["event"], "progress"); + assert_eq!(value["data"]["phase"], "Writing"); + assert_eq!(value["data"]["completed"], 25); + assert_eq!(value["data"]["total"], 100); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 9fb1ccf..0881c30 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,6 +15,7 @@ - [x] Cancellation at safe boundaries and resumable UI progress - [x] Root-only loop-device integration harness using synthetic images without changing discovery policy - [x] CI assertion for the read-only OVMF/QEMU screenshot harness using a deterministic UEFI fixture +- [x] Production writer → disposable virtual USB → OVMF/QEMU end-to-end assertion - Signed release artifacts and udev-driven hotplug refresh ## 0.3 — native adapters diff --git a/docs/rufus-parity.md b/docs/rufus-parity.md index efdc1ac..717d9f0 100644 --- a/docs/rufus-parity.md +++ b/docs/rufus-parity.md @@ -18,7 +18,7 @@ is an original cross-platform implementation; parity means equivalent outcomes, | Raw write verification | byte-range SHA-256 | phase, speed, ETA, verification | phase, speed, ETA, verification | Implemented on Linux | | Windows installer creation | GPT/FAT32 + split WIM | consequence modal + narrow helper + live write | consequence modal + narrow helper + live write | Implemented on Linux, Windows, and macOS | | Windows 11 TPM/Secure Boot/RAM bypass | guarded answer file | flag + clickable toggle | clickable toggle | Implemented | -| Runtime UEFI boot validation | reproducible read-only QEMU/OVMF harness + RGB frame assertion | same script | same script | Implemented in CI with a deterministic UEFI fixture | +| Runtime UEFI boot validation | Bootable write/verify → disposable virtual USB → QEMU/OVMF + RGB frame assertion | same script | same script | Implemented locally and in CI with a deterministic UEFI fixture | | Bad-block/fake-drive test | 1/2/4 destructive patterns | flag + clickable cycle | clickable cycle | Implemented on Linux | | Partition scheme and target firmware choices | GPT or MBR + UEFI | clickable cycle | native select box | Partial: legacy BIOS remains | | FAT/FAT32/NTFS/UDF/exFAT/ext formatting | Windows FAT32 only | automatic only | automatic only | Planned | diff --git a/docs/validation.md b/docs/validation.md index 6349ecc..b3ebbee 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -4,13 +4,14 @@ Bootable separates non-destructive boot validation from destructive device tests ## UEFI smoke test -`scripts/qemu-uefi-smoke.sh` starts QEMU with OVMF, no networking, and the supplied ISO or disk -attached read-only. It sends the optical boot key when testing a CD/DVD image, waits for firmware and -the loader, captures a screenshot, and shuts the VM down. +`scripts/qemu-uefi-smoke.sh` starts QEMU with OVMF, no networking, and the supplied ISO, disk, or +file-backed USB image attached read-only. It sends the optical boot key when testing a CD/DVD image, +waits for firmware and the loader, captures a screenshot, and shuts the VM down. ```bash scripts/qemu-uefi-smoke.sh --cdrom image.iso /tmp/bootable-uefi.png scripts/qemu-uefi-smoke.sh --disk disk.img /tmp/bootable-disk-uefi.png +scripts/qemu-uefi-smoke.sh --usb usb.img /tmp/bootable-usb-uefi.png ``` For automation, pass a six-digit expected average RGB value as the fourth argument. The command @@ -25,6 +26,21 @@ BOOTABLE_QEMU_WAIT_SECONDS=10 BOOTABLE_QEMU_COLOR_TOLERANCE=64 \ /tmp/bootable-uefi-fixture.png 0000aa ``` +The full virtual-USB assertion creates a larger disposable backing file, attaches only that file as a +temporary `/dev/loopN`, writes and byte-verifies the selected image through Bootable's production raw +writer, detaches it, and presents the same file to QEMU as removable USB storage: + +```bash +BOOTABLE_QEMU_WAIT_SECONDS=10 BOOTABLE_QEMU_COLOR_TOLERANCE=64 \ + scripts/qemu-usb-write-uefi-smoke.sh /tmp/bootable-uefi-fixture.iso \ + /tmp/bootable-qemu-usb.png 0000aa +``` + +Administrator authentication is required only to create, test, and detach that exact temporary loop +device. Normal Bootable discovery continues to exclude loop devices, and the harness refuses any +target path that is not `/dev/loopN`. Set `BOOTABLE_ELEVATE=pkexec` on a desktop host to use its +Polkit prompt instead of `sudo`; CI uses the default `sudo` path. + Set `BOOTABLE_QEMU_WAIT_SECONDS`, `BOOTABLE_QEMU_MEMORY`, `BOOTABLE_OVMF_CODE`, or `BOOTABLE_OVMF_VARS` when the host needs different timing or firmware paths. A successful process exit without an expected color proves that OVMF and QEMU accepted the media and produced a frame; @@ -36,7 +52,7 @@ from the project test ISOs. The harness does not claim an operating-system insta ## Physical media -Attach physical devices only with `--disk` and keep QEMU's read-only option intact. Reading a Linux +Attach physical devices only with deliberate operator review and keep QEMU's read-only option intact. Reading a Linux block device commonly requires root or membership in the `disk` group. Do not loosen device-node permissions as a workaround. Bootable's destructive write tests must continue to revalidate a stable, removable, non-system target immediately before erasure. diff --git a/packaging/Packager.toml b/packaging/Packager.toml index b4dfba6..0eff496 100644 --- a/packaging/Packager.toml +++ b/packaging/Packager.toml @@ -1,7 +1,7 @@ name = "bootable" product-name = "Bootable" identifier = "app.bootable.Bootable" -version = "0.1.1" +version = "0.1.2" description = "Create verified boot media from trusted images" long-description = "A safety-first boot media writer with matching desktop and terminal interfaces." homepage = "https://github.com/debpalash/bootable" diff --git a/scripts/qemu-uefi-smoke.sh b/scripts/qemu-uefi-smoke.sh index bd0c758..25f7a9a 100755 --- a/scripts/qemu-uefi-smoke.sh +++ b/scripts/qemu-uefi-smoke.sh @@ -2,7 +2,7 @@ set -eu usage() { - echo "usage: qemu-uefi-smoke.sh [--cdrom|--disk] IMAGE [SCREENSHOT.png] [EXPECTED_RGB]" >&2 + echo "usage: qemu-uefi-smoke.sh [--cdrom|--disk|--usb] IMAGE [SCREENSHOT.png] [EXPECTED_RGB]" >&2 echo " EXPECTED_RGB is six hexadecimal digits; tolerance defaults to 48/channel" >&2 exit 2 } @@ -12,7 +12,7 @@ image="${2:-}" screenshot="${3:-qemu-uefi-smoke.png}" expected_rgb="${4:-}" case "$mode" in - --cdrom|--disk) ;; + --cdrom|--disk|--usb) ;; *) usage ;; esac [ -n "$image" ] || usage @@ -32,8 +32,24 @@ for command in qemu-system-x86_64 socat; do } done -ovmf_code="${BOOTABLE_OVMF_CODE:-/usr/share/OVMF/OVMF_CODE_4M.fd}" -ovmf_vars="${BOOTABLE_OVMF_VARS:-/usr/share/OVMF/OVMF_VARS_4M.fd}" +ovmf_code="${BOOTABLE_OVMF_CODE:-}" +ovmf_vars="${BOOTABLE_OVMF_VARS:-}" +if [ -z "$ovmf_code" ]; then + for candidate in /usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/edk2/x64/OVMF_CODE.4m.fd; do + if [ -r "$candidate" ]; then + ovmf_code="$candidate" + break + fi + done +fi +if [ -z "$ovmf_vars" ]; then + for candidate in /usr/share/OVMF/OVMF_VARS_4M.fd /usr/share/edk2/x64/OVMF_VARS.4m.fd; do + if [ -r "$candidate" ]; then + ovmf_vars="$candidate" + break + fi + done +fi [ -r "$ovmf_code" ] && [ -r "$ovmf_vars" ] || { echo "OVMF firmware was not found; set BOOTABLE_OVMF_CODE and BOOTABLE_OVMF_VARS" >&2 exit 1 @@ -72,6 +88,11 @@ set -- \ if [ "$mode" = "--cdrom" ]; then set -- "$@" -drive "file=$image,media=cdrom,format=raw,readonly=on" +elif [ "$mode" = "--usb" ]; then + set -- "$@" \ + -device qemu-xhci,id=bootable-xhci \ + -drive "if=none,id=bootable-usb,file=$image,format=raw,readonly=on" \ + -device usb-storage,drive=bootable-usb,removable=true,bootindex=1 else set -- "$@" -drive "file=$image,if=virtio,format=raw,readonly=on" fi diff --git a/scripts/qemu-usb-write-uefi-smoke.sh b/scripts/qemu-usb-write-uefi-smoke.sh new file mode 100755 index 0000000..0d9ab86 --- /dev/null +++ b/scripts/qemu-usb-write-uefi-smoke.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: qemu-usb-write-uefi-smoke.sh IMAGE [SCREENSHOT.png] [EXPECTED_RGB]" >&2 + exit 2 +} + +source_image="${1:-}" +screenshot="${2:-qemu-usb-write-uefi-smoke.png}" +expected_rgb="${3:-}" +[ -n "$source_image" ] || usage +[ -r "$source_image" ] || { + echo "Image is not readable: $source_image" >&2 + exit 1 +} + +elevation="${BOOTABLE_ELEVATE:-sudo}" +for command in cargo losetup truncate "$elevation" wc; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is missing: $command" >&2 + exit 1 + } +done + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + "$elevation" "$@" + fi +} + +script_directory="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/bootable-qemu-usb.XXXXXX")" +target_image="$work/virtual-usb.img" +loop_device="" + +cleanup() { + if [ -n "$loop_device" ]; then + as_root losetup --detach "$loop_device" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT HUP INT TERM + +source_size="$(wc -c < "$source_image" | tr -d ' ')" +case "$source_size" in + ""|*[!0-9]*) + echo "Could not determine the image size" >&2 + exit 1 + ;; +esac +[ "$source_size" -gt 0 ] || { + echo "The image is empty" >&2 + exit 1 +} + +# Extra capacity makes the virtual target behave like a USB drive larger than +# the selected image. It remains a disposable file under the private temp dir. +target_size=$((source_size + 64 * 1024 * 1024)) +truncate -s "$target_size" "$target_image" + +echo "Attaching a disposable file-backed target; administrator authentication may be required." +loop_device="$(as_root losetup --find --show "$target_image")" +case "$loop_device" in + /dev/loop[0-9]*) ;; + *) + echo "Refusing unexpected loop-device path: $loop_device" >&2 + exit 1 + ;; +esac + +cargo test -p bootable-core --lib --no-run >/dev/null +test_binary="$( + find target/debug/deps -maxdepth 1 -type f -name 'bootable_core-*' -perm -0100 \ + -printf '%T@ %p\n' | sort -nr | sed -n '1s/^[^ ]* //p' +)" +[ -n "$test_binary" ] && [ -x "$test_binary" ] || { + echo "Could not locate the bootable-core test binary" >&2 + exit 1 +} +case "$test_binary" in + /*) ;; + *) test_binary="$(pwd)/$test_binary" ;; +esac + +as_root env \ + "BOOTABLE_LOOP_SOURCE=$source_image" \ + "BOOTABLE_LOOP_DEVICE=$loop_device" \ + "$test_binary" \ + platform::linux::tests::temporary_loop_device_streams_and_verifies_without_relaxing_discovery \ + --exact --ignored --nocapture + +# QEMU must own the backing file directly, so detach the host loop mapping +# before presenting that same file as removable USB storage to the guest. +as_root losetup --detach "$loop_device" +loop_device="" + +echo "Bootable write and verification passed; booting the result as QEMU USB under UEFI." +"$script_directory/qemu-uefi-smoke.sh" --usb "$target_image" "$screenshot" "$expected_rgb"