Skip to content

Commit 51bdcd1

Browse files
authored
feat: add safe Omarchy client progress protocol (#4)
Co-authored-by: debpalash <debpalash@users.noreply.github.com>
1 parent fc9cea1 commit 51bdcd1

10 files changed

Lines changed: 258 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
- run: cargo build --release --workspace
5555

5656
qemu-uefi-smoke:
57-
name: Read-only UEFI smoke assertion
57+
name: Bootable write → virtual USB → UEFI
5858
runs-on: ubuntu-24.04
5959
steps:
6060
- uses: actions/checkout@v4
@@ -69,9 +69,16 @@ jobs:
6969
BOOTABLE_QEMU_WAIT_SECONDS: 10
7070
BOOTABLE_QEMU_COLOR_TOLERANCE: 64
7171
run: scripts/qemu-uefi-smoke.sh --cdrom /tmp/bootable-uefi-fixture.iso /tmp/bootable-uefi-fixture.png 0000aa
72+
- name: Write, verify, and boot a disposable virtual USB
73+
env:
74+
BOOTABLE_QEMU_WAIT_SECONDS: 10
75+
BOOTABLE_QEMU_COLOR_TOLERANCE: 64
76+
run: scripts/qemu-usb-write-uefi-smoke.sh /tmp/bootable-uefi-fixture.iso /tmp/bootable-qemu-usb.png 0000aa
7277
- uses: actions/upload-artifact@v4
7378
if: always()
7479
with:
7580
name: qemu-uefi-smoke-frame
76-
path: /tmp/bootable-uefi-fixture.png
81+
path: |
82+
/tmp/bootable-uefi-fixture.png
83+
/tmp/bootable-qemu-usb.png
7784
if-no-files-found: warn

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ default-members = [
1313
resolver = "2"
1414

1515
[workspace.package]
16-
version = "0.1.1"
16+
version = "0.1.2"
1717
edition = "2024"
1818
license = "Apache-2.0"
1919
rust-version = "1.88"

apps/bootable-tui/src/main.rs

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ enum Commands {
128128
target: String,
129129
#[arg(long, value_name = "EXACT_PHRASE")]
130130
confirm: Option<String>,
131+
/// Emit newline-delimited JSON progress events for trusted clients.
132+
#[arg(long)]
133+
json_progress: bool,
131134
#[command(flatten)]
132135
windows: WindowsArgs,
133136
#[arg(long, default_value = "off", value_name = "off|1|2|4")]
@@ -207,13 +210,15 @@ fn main() -> Result<()> {
207210
image,
208211
target,
209212
confirm,
213+
json_progress,
210214
windows,
211215
bad_block_check,
212216
}) => write_image(
213217
&engine,
214218
image,
215219
&target,
216220
confirm,
221+
json_progress,
217222
write_options(windows, bad_block_check),
218223
),
219224
None if io::stdout().is_terminal() => run_tui(engine, cli.image),
@@ -451,19 +456,30 @@ fn write_image(
451456
image: PathBuf,
452457
target: &str,
453458
confirmation: Option<String>,
459+
json_progress: bool,
454460
options: WriteOptions,
455461
) -> Result<()> {
456462
let plan = engine.prepare_with_options(image, target, options)?;
457463
let Some(confirmation) = confirmation else {
458464
render_plan_text(&plan);
459465
bail!(
460-
"nothing was written; repeat with --confirm '{}' as root/admin",
466+
"nothing was written; repeat with --confirm '{}'",
461467
plan.confirmation_phrase
462468
);
463469
};
464-
let mut reporter = ProgressReporter::default();
465-
engine.write(&plan, &confirmation, |progress| reporter.print(progress))?;
466-
Ok(())
470+
let mut reporter = ProgressReporter::new(json_progress);
471+
let result =
472+
engine.write_with_privilege(&plan, &confirmation, |progress| reporter.print(progress));
473+
match result {
474+
Ok(()) => {
475+
reporter.finished();
476+
Ok(())
477+
}
478+
Err(error) => {
479+
reporter.failed(&error.to_string());
480+
Err(error.into())
481+
}
482+
}
467483
}
468484

469485
fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteOptions {
@@ -491,9 +507,18 @@ fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteO
491507
struct ProgressReporter {
492508
phase: Option<ProgressPhase>,
493509
percentage: Option<u64>,
510+
json: bool,
494511
}
495512

496513
impl ProgressReporter {
514+
fn new(json: bool) -> Self {
515+
Self {
516+
phase: None,
517+
percentage: None,
518+
json,
519+
}
520+
}
521+
497522
fn print(&mut self, progress: Progress) {
498523
let percentage = progress
499524
.total
@@ -504,13 +529,39 @@ impl ProgressReporter {
504529
if !phase_changed && !percentage_changed {
505530
return;
506531
}
532+
if self.json {
533+
println!("{}", progress_event_json(&progress));
534+
let _ = io::Write::flush(&mut io::stdout());
535+
self.phase = Some(progress.phase);
536+
self.percentage = percentage;
537+
return;
538+
}
507539
let amount = percentage
508540
.map(|value| format!("{value:>3}%"))
509541
.unwrap_or_else(|| "...".into());
510542
eprintln!("{amount} {:?}: {}", progress.phase, progress.message);
511543
self.phase = Some(progress.phase);
512544
self.percentage = percentage;
513545
}
546+
547+
fn finished(&self) {
548+
if self.json {
549+
println!("{{\"event\":\"finished\"}}");
550+
}
551+
}
552+
553+
fn failed(&self, message: &str) {
554+
if self.json {
555+
println!(
556+
"{}",
557+
serde_json::json!({ "event": "failed", "data": { "message": message } })
558+
);
559+
}
560+
}
561+
}
562+
563+
fn progress_event_json(progress: &Progress) -> String {
564+
serde_json::json!({ "event": "progress", "data": progress }).to_string()
514565
}
515566

516567
fn render_plan_text(plan: &WritePlan) {
@@ -4911,9 +4962,11 @@ fn device_change_message(added: usize, removed: usize) -> String {
49114962
#[cfg(test)]
49124963
mod layout_tests {
49134964
use super::{
4914-
WorkspaceFocus, advanced_height, application_area, brand_lockup, centered_button_area,
4915-
grid_areas, main_shell_layout, windows_option_columns, workspace_height,
4965+
Cli, Commands, Progress, ProgressPhase, WorkspaceFocus, advanced_height, application_area,
4966+
brand_lockup, centered_button_area, grid_areas, main_shell_layout, progress_event_json,
4967+
windows_option_columns, workspace_height,
49164968
};
4969+
use clap::Parser;
49174970
use ratatui::layout::Rect;
49184971

49194972
#[test]
@@ -4996,4 +5049,40 @@ mod layout_tests {
49965049
assert!(lines[0].to_string().contains("┌┬┬┐ BOOTABLE α"));
49975050
assert!(lines[1].to_string().contains("╰♨─╯"));
49985051
}
5052+
5053+
#[test]
5054+
fn write_json_progress_is_an_explicit_client_mode() {
5055+
let cli = Cli::try_parse_from([
5056+
"bootable",
5057+
"write",
5058+
"image.iso",
5059+
"/dev/removable",
5060+
"--confirm",
5061+
"ERASE /dev/removable TEST",
5062+
"--json-progress",
5063+
])
5064+
.expect("valid client invocation");
5065+
assert!(matches!(
5066+
cli.command,
5067+
Some(Commands::Write {
5068+
json_progress: true,
5069+
..
5070+
})
5071+
));
5072+
}
5073+
5074+
#[test]
5075+
fn progress_events_are_stable_newline_json_payloads() {
5076+
let event = progress_event_json(&Progress {
5077+
phase: ProgressPhase::Writing,
5078+
completed: 25,
5079+
total: Some(100),
5080+
message: "Writing and verifying".into(),
5081+
});
5082+
let value: serde_json::Value = serde_json::from_str(&event).expect("valid JSON");
5083+
assert_eq!(value["event"], "progress");
5084+
assert_eq!(value["data"]["phase"], "Writing");
5085+
assert_eq!(value["data"]["completed"], 25);
5086+
assert_eq!(value["data"]["total"], 100);
5087+
}
49995088
}

docs/roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [x] Cancellation at safe boundaries and resumable UI progress
1616
- [x] Root-only loop-device integration harness using synthetic images without changing discovery policy
1717
- [x] CI assertion for the read-only OVMF/QEMU screenshot harness using a deterministic UEFI fixture
18+
- [x] Production writer → disposable virtual USB → OVMF/QEMU end-to-end assertion
1819
- Signed release artifacts and udev-driven hotplug refresh
1920

2021
## 0.3 — native adapters

docs/rufus-parity.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ is an original cross-platform implementation; parity means equivalent outcomes,
1818
| Raw write verification | byte-range SHA-256 | phase, speed, ETA, verification | phase, speed, ETA, verification | Implemented on Linux |
1919
| 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 |
2020
| Windows 11 TPM/Secure Boot/RAM bypass | guarded answer file | flag + clickable toggle | clickable toggle | Implemented |
21-
| 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 |
21+
| 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 |
2222
| Bad-block/fake-drive test | 1/2/4 destructive patterns | flag + clickable cycle | clickable cycle | Implemented on Linux |
2323
| Partition scheme and target firmware choices | GPT or MBR + UEFI | clickable cycle | native select box | Partial: legacy BIOS remains |
2424
| FAT/FAT32/NTFS/UDF/exFAT/ext formatting | Windows FAT32 only | automatic only | automatic only | Planned |

docs/validation.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ Bootable separates non-destructive boot validation from destructive device tests
44

55
## UEFI smoke test
66

7-
`scripts/qemu-uefi-smoke.sh` starts QEMU with OVMF, no networking, and the supplied ISO or disk
8-
attached read-only. It sends the optical boot key when testing a CD/DVD image, waits for firmware and
9-
the loader, captures a screenshot, and shuts the VM down.
7+
`scripts/qemu-uefi-smoke.sh` starts QEMU with OVMF, no networking, and the supplied ISO, disk, or
8+
file-backed USB image attached read-only. It sends the optical boot key when testing a CD/DVD image,
9+
waits for firmware and the loader, captures a screenshot, and shuts the VM down.
1010

1111
```bash
1212
scripts/qemu-uefi-smoke.sh --cdrom image.iso /tmp/bootable-uefi.png
1313
scripts/qemu-uefi-smoke.sh --disk disk.img /tmp/bootable-disk-uefi.png
14+
scripts/qemu-uefi-smoke.sh --usb usb.img /tmp/bootable-usb-uefi.png
1415
```
1516

1617
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 \
2526
/tmp/bootable-uefi-fixture.png 0000aa
2627
```
2728

29+
The full virtual-USB assertion creates a larger disposable backing file, attaches only that file as a
30+
temporary `/dev/loopN`, writes and byte-verifies the selected image through Bootable's production raw
31+
writer, detaches it, and presents the same file to QEMU as removable USB storage:
32+
33+
```bash
34+
BOOTABLE_QEMU_WAIT_SECONDS=10 BOOTABLE_QEMU_COLOR_TOLERANCE=64 \
35+
scripts/qemu-usb-write-uefi-smoke.sh /tmp/bootable-uefi-fixture.iso \
36+
/tmp/bootable-qemu-usb.png 0000aa
37+
```
38+
39+
Administrator authentication is required only to create, test, and detach that exact temporary loop
40+
device. Normal Bootable discovery continues to exclude loop devices, and the harness refuses any
41+
target path that is not `/dev/loopN`. Set `BOOTABLE_ELEVATE=pkexec` on a desktop host to use its
42+
Polkit prompt instead of `sudo`; CI uses the default `sudo` path.
43+
2844
Set `BOOTABLE_QEMU_WAIT_SECONDS`, `BOOTABLE_QEMU_MEMORY`, `BOOTABLE_OVMF_CODE`, or
2945
`BOOTABLE_OVMF_VARS` when the host needs different timing or firmware paths. A successful process
3046
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
3652

3753
## Physical media
3854

39-
Attach physical devices only with `--disk` and keep QEMU's read-only option intact. Reading a Linux
55+
Attach physical devices only with deliberate operator review and keep QEMU's read-only option intact. Reading a Linux
4056
block device commonly requires root or membership in the `disk` group. Do not loosen device-node
4157
permissions as a workaround. Bootable's destructive write tests must continue to revalidate a stable,
4258
removable, non-system target immediately before erasure.

packaging/Packager.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "bootable"
22
product-name = "Bootable"
33
identifier = "app.bootable.Bootable"
4-
version = "0.1.1"
4+
version = "0.1.2"
55
description = "Create verified boot media from trusted images"
66
long-description = "A safety-first boot media writer with matching desktop and terminal interfaces."
77
homepage = "https://github.com/debpalash/bootable"

scripts/qemu-uefi-smoke.sh

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
set -eu
33

44
usage() {
5-
echo "usage: qemu-uefi-smoke.sh [--cdrom|--disk] IMAGE [SCREENSHOT.png] [EXPECTED_RGB]" >&2
5+
echo "usage: qemu-uefi-smoke.sh [--cdrom|--disk|--usb] IMAGE [SCREENSHOT.png] [EXPECTED_RGB]" >&2
66
echo " EXPECTED_RGB is six hexadecimal digits; tolerance defaults to 48/channel" >&2
77
exit 2
88
}
@@ -12,7 +12,7 @@ image="${2:-}"
1212
screenshot="${3:-qemu-uefi-smoke.png}"
1313
expected_rgb="${4:-}"
1414
case "$mode" in
15-
--cdrom|--disk) ;;
15+
--cdrom|--disk|--usb) ;;
1616
*) usage ;;
1717
esac
1818
[ -n "$image" ] || usage
@@ -32,8 +32,24 @@ for command in qemu-system-x86_64 socat; do
3232
}
3333
done
3434

35-
ovmf_code="${BOOTABLE_OVMF_CODE:-/usr/share/OVMF/OVMF_CODE_4M.fd}"
36-
ovmf_vars="${BOOTABLE_OVMF_VARS:-/usr/share/OVMF/OVMF_VARS_4M.fd}"
35+
ovmf_code="${BOOTABLE_OVMF_CODE:-}"
36+
ovmf_vars="${BOOTABLE_OVMF_VARS:-}"
37+
if [ -z "$ovmf_code" ]; then
38+
for candidate in /usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/edk2/x64/OVMF_CODE.4m.fd; do
39+
if [ -r "$candidate" ]; then
40+
ovmf_code="$candidate"
41+
break
42+
fi
43+
done
44+
fi
45+
if [ -z "$ovmf_vars" ]; then
46+
for candidate in /usr/share/OVMF/OVMF_VARS_4M.fd /usr/share/edk2/x64/OVMF_VARS.4m.fd; do
47+
if [ -r "$candidate" ]; then
48+
ovmf_vars="$candidate"
49+
break
50+
fi
51+
done
52+
fi
3753
[ -r "$ovmf_code" ] && [ -r "$ovmf_vars" ] || {
3854
echo "OVMF firmware was not found; set BOOTABLE_OVMF_CODE and BOOTABLE_OVMF_VARS" >&2
3955
exit 1
@@ -72,6 +88,11 @@ set -- \
7288

7389
if [ "$mode" = "--cdrom" ]; then
7490
set -- "$@" -drive "file=$image,media=cdrom,format=raw,readonly=on"
91+
elif [ "$mode" = "--usb" ]; then
92+
set -- "$@" \
93+
-device qemu-xhci,id=bootable-xhci \
94+
-drive "if=none,id=bootable-usb,file=$image,format=raw,readonly=on" \
95+
-device usb-storage,drive=bootable-usb,removable=true,bootindex=1
7596
else
7697
set -- "$@" -drive "file=$image,if=virtio,format=raw,readonly=on"
7798
fi

0 commit comments

Comments
 (0)