Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
101 changes: 95 additions & 6 deletions apps/bootable-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ enum Commands {
target: String,
#[arg(long, value_name = "EXACT_PHRASE")]
confirm: Option<String>,
/// 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")]
Expand Down Expand Up @@ -207,13 +210,15 @@ fn main() -> Result<()> {
image,
target,
confirm,
json_progress,
windows,
bad_block_check,
}) => write_image(
&engine,
image,
&target,
confirm,
json_progress,
write_options(windows, bad_block_check),
),
None if io::stdout().is_terminal() => run_tui(engine, cli.image),
Expand Down Expand Up @@ -451,19 +456,30 @@ fn write_image(
image: PathBuf,
target: &str,
confirmation: Option<String>,
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 {
Expand Down Expand Up @@ -491,9 +507,18 @@ fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteO
struct ProgressReporter {
phase: Option<ProgressPhase>,
percentage: Option<u64>,
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
Expand All @@ -504,13 +529,39 @@ 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());
eprintln!("{amount} {:?}: {}", progress.phase, progress.message);
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) {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/rufus-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 20 additions & 4 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packaging/Packager.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
29 changes: 25 additions & 4 deletions scripts/qemu-uefi-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading