Skip to main content

flashkraft_core/
flash_helper.rs

1//! Flash Pipeline
2//!
3//! Implements the entire privileged flash pipeline in-process.
4//!
5//! ## Privilege model
6//!
7//! The installed binary carries the **setuid-root** bit
8//! (`sudo chmod u+s /usr/bin/flashkraft`).  At process startup `main.rs`
9//! calls [`set_real_uid`] to record the unprivileged user's UID.
10//!
11//! When the pipeline needs to open a raw block device it temporarily
12//! escalates to root via `nix::unistd::seteuid(0)`, opens the file
13//! descriptor, then immediately drops back to the real UID.  Root is held
14//! for less than one millisecond.
15//!
16//! ## Progress reporting
17//!
18//! The pipeline runs on a dedicated blocking thread spawned by the flash
19//! subscription.  Progress is reported by sending [`FlashEvent`] values
20//! through a [`std::sync::mpsc::Sender`] — no child process, no stdout
21//! parsing, no IPC protocol.
22//!
23//! ## Pipeline stages
24//!
25//! 1. Validate inputs (image exists and is non-empty, device exists, not a partition node)
26//! 2. Unmount all partitions of the target device (lazy / `MNT_DETACH`)
27//! 3. Write the image in 4 MiB blocks, reporting progress every 400 ms
28//! 4. `fsync` the device fd (hard error on failure)
29//! 5. `fdatasync` + global `sync()` (belt-and-suspenders)
30//! 6. `BLKRRPART` ioctl — ask the kernel to re-read the partition table
31//! 7. SHA-256 verify: hash the source image, hash the first N bytes of the device, compare
32
33#[cfg(unix)]
34use nix::libc;
35use std::io::{self, Read, Write};
36use std::path::Path;
37use std::sync::{
38    atomic::{AtomicBool, Ordering},
39    mpsc, Arc, OnceLock,
40};
41use std::time::{Duration, Instant};
42
43// ---------------------------------------------------------------------------
44// Constants
45// ---------------------------------------------------------------------------
46
47/// Write / read-back buffer: 4 MiB is a sweet spot for USB throughput.
48const BLOCK_SIZE: usize = 4 * 1024 * 1024;
49
50/// Minimum interval between `FlashEvent::Progress` emissions.
51const PROGRESS_INTERVAL: Duration = Duration::from_millis(400);
52
53// ---------------------------------------------------------------------------
54// Real-UID registry
55// ---------------------------------------------------------------------------
56
57/// The unprivileged UID of the user who launched the process.
58///
59/// Captured in `main.rs` via `nix::unistd::getuid()` before any `seteuid`
60/// call and stored here via [`set_real_uid`].
61static REAL_UID: OnceLock<u32> = OnceLock::new();
62
63/// Store the real (unprivileged) UID of the process owner.
64///
65/// Must be called once from `main()` before any flash operation.
66/// On non-Unix platforms this is a no-op.
67pub fn set_real_uid(uid: u32) {
68    let _ = REAL_UID.set(uid);
69}
70
71/// Return `true` when the process is currently running with effective root
72/// privileges (i.e. `geteuid() == 0`).
73///
74/// On non-Unix platforms this always returns `false` — callers should use
75/// the Windows Administrator check instead.
76pub fn is_privileged() -> bool {
77    #[cfg(unix)]
78    {
79        nix::unistd::geteuid().is_root()
80    }
81    #[cfg(not(unix))]
82    {
83        false
84    }
85}
86
87/// Attempt to re-exec the current binary with root privileges via `pkexec`
88/// or `sudo -E`, whichever is found first on `PATH`.
89///
90/// This is called **on demand** — e.g. when the user clicks Flash and we
91/// detect that `is_privileged()` is `false` — rather than unconditionally
92/// at startup.  Because `execvp` replaces the current process image on
93/// success, this function only returns when neither escalation helper is
94/// available or the user declined (cancelled the polkit dialog / Ctrl-C'd
95/// the sudo prompt).
96///
97/// `FLASHKRAFT_ESCALATED=1` is injected into the child environment so that
98/// the re-exec'd process skips this call and does not loop.
99///
100/// # Safety
101///
102/// Safe to call from any thread, but must be called before the Iced event
103/// loop has started spawning threads that hold OS resources (file
104/// descriptors, mutexes) that `execvp` would implicitly close/reset.
105/// Calling it from the `update` handler (on the Iced main thread, before
106/// the flash subscription starts) satisfies this requirement.
107#[cfg(unix)]
108pub fn reexec_as_root() {
109    // Never attempt privilege escalation during `cargo test` — sudo/pkexec
110    // would block the test runner waiting for a password prompt.
111    //
112    // IMPORTANT: `#[cfg(test)]` is only set on the *root* crate being tested.
113    // When `flashkraft-core` is compiled as a *dependency* of another crate's
114    // test binary (e.g. `flashkraft-gui`'s tests), it is compiled in normal
115    // (non-test) mode, so `#[cfg(test)]` does NOT fire here.
116    //
117    // We therefore use a runtime heuristic: cargo test binary paths contain
118    // a hash-suffixed name under `target/debug/deps/`, e.g.:
119    //   …/target/debug/deps/flashkraft_gui-a76f74e119b55607
120    // We also check for the FLASHKRAFT_NO_REEXEC env var as an explicit opt-out,
121    // and for NEXTEST_TEST_FILTER which nextest sets.
122    if is_running_under_test_harness() {
123        return;
124    }
125
126    // Compile-time guard for crate-local unit tests (when core IS the root
127    // test crate and #[cfg(test)] IS honoured).
128    #[cfg(test)]
129    return;
130
131    #[cfg(not(test))]
132    reexec_as_root_inner();
133}
134
135/// Returns `true` when the current process appears to be a `cargo test` (or
136/// nextest) test-runner binary, based on runtime evidence.
137///
138/// This is needed because `#[cfg(test)]` is **not** propagated to dependency
139/// crates — only the root crate being tested gets the flag.
140#[cfg(unix)]
141fn is_running_under_test_harness() -> bool {
142    // Explicit opt-out env var — tests can set this if needed.
143    if std::env::var("FLASHKRAFT_NO_REEXEC").is_ok() {
144        return true;
145    }
146
147    // nextest sets this in every test process.
148    if std::env::var("NEXTEST_TEST_FILTER").is_ok() {
149        return true;
150    }
151
152    // cargo test passes `--test-threads` (or related flags) on argv.
153    // More importantly, the test binary itself is passed the test filter as
154    // a positional argv — but the most reliable signal is the executable path:
155    // cargo always places test binaries under `target/debug/deps/<name>-<hash>`
156    // or `target/<profile>/deps/<name>-<hash>`.
157    //
158    // We look for `/deps/` in the executable path as a strong indicator.
159    if let Ok(exe) = std::env::current_exe() {
160        let path_str = exe.to_string_lossy();
161        // All cargo test binaries live under a `deps` directory.
162        if path_str.contains("/deps/") {
163            return true;
164        }
165        // Also catch `target\deps\` on Windows.
166        if path_str.contains("\\deps\\") {
167            return true;
168        }
169    }
170
171    false
172}
173
174#[cfg(all(unix, not(test)))]
175fn reexec_as_root_inner() {
176    use std::ffi::CString;
177
178    // Guard: the re-exec'd copy sets this so we don't loop forever.
179    if std::env::var("FLASHKRAFT_ESCALATED").as_deref() == Ok("1") {
180        return;
181    }
182
183    let self_exe = match std::fs::read_link("/proc/self/exe").or_else(|_| std::env::current_exe()) {
184        Ok(p) => p,
185        Err(_) => return,
186    };
187    let self_exe_str = match self_exe.to_str() {
188        Some(s) => s.to_owned(),
189        None => return,
190    };
191
192    let extra_args: Vec<String> = std::env::args().skip(1).collect();
193
194    // Tell the child it was already escalated so it won't recurse.
195    std::env::set_var("FLASHKRAFT_ESCALATED", "1");
196
197    // ── Try pkexec first (graphical polkit dialog) ────────────────────────────
198    if unix_which_exists("pkexec") {
199        let mut argv: Vec<CString> = Vec::new();
200        argv.push(unix_c_str("pkexec"));
201        argv.push(unix_c_str(&self_exe_str));
202        for a in &extra_args {
203            argv.push(unix_c_str(a));
204        }
205        let _ = nix::unistd::execvp(&unix_c_str("pkexec"), &argv);
206    }
207
208    // ── Try sudo -E (terminal fallback) ───────────────────────────────────────
209    if unix_which_exists("sudo") {
210        let mut argv: Vec<CString> = Vec::new();
211        argv.push(unix_c_str("sudo"));
212        argv.push(unix_c_str("-E")); // preserve DISPLAY / WAYLAND_DISPLAY
213        argv.push(unix_c_str(&self_exe_str));
214        for a in &extra_args {
215            argv.push(unix_c_str(a));
216        }
217        let _ = nix::unistd::execvp(&unix_c_str("sudo"), &argv);
218    }
219
220    // Neither helper available — remove the guard and fall through unprivileged.
221    std::env::remove_var("FLASHKRAFT_ESCALATED");
222}
223
224/// Stub for non-Unix targets so call sites compile without `#[cfg]` guards.
225#[cfg(not(unix))]
226pub fn reexec_as_root() {}
227
228/// Return `true` if `name` is an executable file reachable via `PATH`.
229#[cfg(all(unix, not(test)))]
230fn unix_which_exists(name: &str) -> bool {
231    use std::os::unix::fs::PermissionsExt;
232    if let Ok(path_var) = std::env::var("PATH") {
233        for dir in path_var.split(':') {
234            let candidate = std::path::Path::new(dir).join(name);
235            if let Ok(meta) = std::fs::metadata(&candidate) {
236                if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
237                    return true;
238                }
239            }
240        }
241    }
242    false
243}
244
245/// Build a `CString`, replacing embedded NUL bytes with `?`.
246#[cfg(all(unix, not(test)))]
247fn unix_c_str(s: &str) -> std::ffi::CString {
248    let sanitised: Vec<u8> = s.bytes().map(|b| if b == 0 { b'?' } else { b }).collect();
249    std::ffi::CString::new(sanitised).unwrap_or_else(|_| std::ffi::CString::new("?").unwrap())
250}
251
252/// Retrieve the stored real UID, falling back to the current effective UID.
253#[cfg(unix)]
254fn real_uid() -> nix::unistd::Uid {
255    let raw = REAL_UID
256        .get()
257        .copied()
258        .unwrap_or_else(|| nix::unistd::getuid().as_raw());
259    nix::unistd::Uid::from_raw(raw)
260}
261
262// ---------------------------------------------------------------------------
263// Public types
264// ---------------------------------------------------------------------------
265
266/// A stage in the five-step flash pipeline.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum FlashStage {
269    /// Initial state before the pipeline starts.
270    Starting,
271    /// All partitions of the target device are being lazily unmounted.
272    Unmounting,
273    /// The image is being written to the block device in 4 MiB chunks.
274    Writing,
275    /// Kernel write-back caches are being flushed (`fsync` / `sync`).
276    Syncing,
277    /// The kernel is asked to re-read the partition table (`BLKRRPART`).
278    Rereading,
279    /// SHA-256 of the source image is compared against a read-back of the device.
280    Verifying,
281    /// The entire pipeline completed successfully.
282    Done,
283    /// The pipeline terminated with an error.
284    Failed(String),
285}
286
287impl std::fmt::Display for FlashStage {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            FlashStage::Starting => write!(f, "Starting…"),
291            FlashStage::Unmounting => write!(f, "Unmounting partitions…"),
292            FlashStage::Writing => write!(f, "Writing image to device…"),
293            FlashStage::Syncing => write!(f, "Flushing write buffers…"),
294            FlashStage::Rereading => write!(f, "Refreshing partition table…"),
295            FlashStage::Verifying => write!(f, "Verifying written data…"),
296            FlashStage::Done => write!(f, "Flash complete!"),
297            FlashStage::Failed(m) => write!(f, "Failed: {m}"),
298        }
299    }
300}
301
302impl FlashStage {
303    /// Map this pipeline stage to the minimum overall-progress-bar floor
304    /// it should hold when it starts.
305    ///
306    /// The write phase occupies 0–80 % via [`FlashEvent::Progress`] events;
307    /// post-write stages advance the floor so the bar keeps moving:
308    ///
309    /// | Stage        | Floor |
310    /// |--------------|-------|
311    /// | Syncing      | 80 %  |
312    /// | Rereading    | 88 %  |
313    /// | Verifying    | 92 %  |
314    /// | everything else | 0 % |
315    pub fn progress_floor(&self) -> f32 {
316        match self {
317            FlashStage::Syncing => 0.80,
318            FlashStage::Rereading => 0.88,
319            FlashStage::Verifying => 0.92,
320            _ => 0.0,
321        }
322    }
323}
324
325/// Compute the overall verification progress (0.0–1.0) from a single-pass
326/// fraction.
327///
328/// The verify pipeline runs two passes:
329/// - `"image"` pass  → hashes the source image file    (contributes 0.0–0.5)
330/// - `"device"` pass → reads back the written device   (contributes 0.5–1.0)
331///
332/// `pass_fraction` must already be clamped to `[0.0, 1.0]`.
333///
334/// # Example
335/// ```
336/// use flashkraft_core::flash_helper::verify_overall_progress;
337/// assert_eq!(verify_overall_progress("image",  0.0), 0.0);
338/// assert_eq!(verify_overall_progress("image",  1.0), 0.5);
339/// assert_eq!(verify_overall_progress("device", 0.0), 0.5);
340/// assert_eq!(verify_overall_progress("device", 1.0), 1.0);
341/// ```
342pub fn verify_overall_progress(phase: &str, pass_fraction: f32) -> f32 {
343    if phase == "image" {
344        pass_fraction * 0.5
345    } else {
346        0.5 + pass_fraction * 0.5
347    }
348}
349
350/// A typed event emitted by the flash pipeline.
351///
352/// Sent over [`std::sync::mpsc`] to the async Iced subscription — no
353/// serialisation, no text parsing.
354#[derive(Debug, Clone)]
355pub enum FlashEvent {
356    /// A pipeline stage transition.
357    Stage(FlashStage),
358    /// Write-progress update.
359    Progress {
360        bytes_written: u64,
361        total_bytes: u64,
362        speed_mb_s: f32,
363    },
364    /// Verification read-back progress update.
365    ///
366    /// Emitted during both the image-hash pass and the device read-back pass.
367    /// `phase` is `"image"` for the source-hash pass and `"device"` for the
368    /// read-back pass.  `bytes_read` and `total_bytes` are the counts for the
369    /// current pass only; the overall verify progress should be computed as:
370    ///
371    /// ```text
372    /// if phase == "image"  { bytes_read / total_bytes * 0.5 }
373    /// if phase == "device" { 0.5 + bytes_read / total_bytes * 0.5 }
374    /// ```
375    VerifyProgress {
376        phase: &'static str,
377        bytes_read: u64,
378        total_bytes: u64,
379        speed_mb_s: f32,
380    },
381    /// Informational log message (not an error).
382    Log(String),
383    /// The pipeline finished successfully.
384    Done,
385    /// The pipeline failed; the string is a human-readable error.
386    Error(String),
387}
388
389// ---------------------------------------------------------------------------
390// Unified frontend event type
391// ---------------------------------------------------------------------------
392
393/// A normalised progress event suitable for consumption by any frontend
394/// (Iced GUI, Ratatui TUI, or future integrations).
395///
396/// Both the GUI's `FlashProgress` and the TUI's `FlashEvent` wrapper types
397/// were independently duplicating this shape.  By defining it once in core
398/// and converting from [`FlashEvent`] via [`From`], each frontend only needs
399/// to bridge this into its own message/command type.
400///
401/// Key differences from the raw [`FlashEvent`]:
402/// - `Progress` carries a normalised `0.0–1.0` write fraction (the raw event
403///   only carries raw byte counts; the fraction is computed here).
404/// - `VerifyProgress` carries a pre-computed `overall` spanning both passes
405///   (via [`verify_overall_progress`]) so frontends never need to duplicate
406///   that formula.
407/// - `Stage` and `Log` are both collapsed into `Message(String)` since both
408///   frontends treat them as human-readable status text.
409/// - `Done` / `Error` become `Completed` / `Failed` to match conventional
410///   naming in UI code.
411#[derive(Debug, Clone)]
412pub enum FlashUpdate {
413    /// Write progress.
414    ///
415    /// `progress` is `bytes_written / total_bytes` clamped to `[0.0, 1.0]`.
416    Progress {
417        progress: f32,
418        bytes_written: u64,
419        speed_mb_s: f32,
420    },
421    /// Verification read-back progress spanning both passes.
422    ///
423    /// `overall` is in `[0.0, 1.0]`:
424    ///   - image pass  → `[0.0, 0.5]`
425    ///   - device pass → `[0.5, 1.0]`
426    VerifyProgress {
427        phase: &'static str,
428        overall: f32,
429        bytes_read: u64,
430        total_bytes: u64,
431        speed_mb_s: f32,
432    },
433    /// Human-readable status text (stage label or log line).
434    Message(String),
435    /// The flash pipeline finished successfully.
436    Completed,
437    /// The flash pipeline failed; the string is a human-readable error.
438    Failed(String),
439}
440
441impl From<FlashEvent> for FlashUpdate {
442    /// Convert a raw pipeline [`FlashEvent`] into a [`FlashUpdate`].
443    ///
444    /// - `Progress` raw byte counts → normalised `0.0–1.0` fraction.
445    /// - `VerifyProgress` per-pass fraction → overall `0.0–1.0` via
446    ///   [`verify_overall_progress`].
447    /// - `Stage` display string and `Log` string → `Message`.
448    /// - `Done` → `Completed`, `Error` → `Failed`.
449    fn from(event: FlashEvent) -> Self {
450        match event {
451            FlashEvent::Progress {
452                bytes_written,
453                total_bytes,
454                speed_mb_s,
455            } => {
456                let progress = if total_bytes > 0 {
457                    (bytes_written as f64 / total_bytes as f64).clamp(0.0, 1.0) as f32
458                } else {
459                    0.0
460                };
461                FlashUpdate::Progress {
462                    progress,
463                    bytes_written,
464                    speed_mb_s,
465                }
466            }
467
468            FlashEvent::VerifyProgress {
469                phase,
470                bytes_read,
471                total_bytes,
472                speed_mb_s,
473            } => {
474                let pass_fraction = if total_bytes > 0 {
475                    (bytes_read as f64 / total_bytes as f64).clamp(0.0, 1.0) as f32
476                } else {
477                    0.0
478                };
479                let overall = verify_overall_progress(phase, pass_fraction);
480                FlashUpdate::VerifyProgress {
481                    phase,
482                    overall,
483                    bytes_read,
484                    total_bytes,
485                    speed_mb_s,
486                }
487            }
488
489            FlashEvent::Stage(stage) => FlashUpdate::Message(stage.to_string()),
490            FlashEvent::Log(msg) => FlashUpdate::Message(msg),
491            FlashEvent::Done => FlashUpdate::Completed,
492            FlashEvent::Error(e) => FlashUpdate::Failed(e),
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Public entry point
499// ---------------------------------------------------------------------------
500
501/// Run the full flash pipeline in the **calling thread**.
502///
503/// This function is blocking and must be called from a dedicated
504/// `std::thread::spawn` thread, not from an async executor.
505///
506/// # Arguments
507///
508/// * `image_path`  – path to the source image file
509/// * `device_path` – path to the target block device (e.g. `/dev/sdb`)
510/// * `tx`          – channel to send [`FlashEvent`] progress updates
511/// * `cancel`      – set to `true` to abort the pipeline between blocks
512pub fn run_pipeline(
513    image_path: &str,
514    device_path: &str,
515    tx: mpsc::Sender<FlashEvent>,
516    cancel: Arc<AtomicBool>,
517) {
518    if let Err(e) = flash_pipeline(image_path, device_path, &tx, cancel) {
519        let _ = tx.send(FlashEvent::Error(e));
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Top-level pipeline
525// ---------------------------------------------------------------------------
526
527fn send(tx: &mpsc::Sender<FlashEvent>, event: FlashEvent) {
528    // If the receiver is gone the GUI has been closed — ignore silently.
529    let _ = tx.send(event);
530}
531
532fn flash_pipeline(
533    image_path: &str,
534    device_path: &str,
535    tx: &mpsc::Sender<FlashEvent>,
536    cancel: Arc<AtomicBool>,
537) -> Result<(), String> {
538    // ── Validate inputs ──────────────────────────────────────────────────────
539    if !Path::new(image_path).is_file() {
540        return Err(format!("Image file not found: {image_path}"));
541    }
542
543    if !Path::new(device_path).exists() {
544        return Err(format!("Target device not found: {device_path}"));
545    }
546
547    // Guard against partition nodes (e.g. /dev/sdb1 instead of /dev/sdb).
548    #[cfg(target_os = "linux")]
549    reject_partition_node(device_path)?;
550
551    let image_size = std::fs::metadata(image_path)
552        .map_err(|e| format!("Cannot stat image: {e}"))?
553        .len();
554
555    if image_size == 0 {
556        return Err("Image file is empty".to_string());
557    }
558
559    // ── Check device is not already in use ───────────────────────────────────
560    // Open the device O_RDONLY | O_EXCL — if another process (e.g. a second
561    // flashkraft instance) already has it open for writing this fails
562    // immediately with EBUSY, giving the user a clear message instead of
563    // appearing to hang during unmount.
564    #[cfg(target_os = "linux")]
565    {
566        use std::os::unix::fs::OpenOptionsExt;
567        let busy = std::fs::OpenOptions::new()
568            .read(true)
569            .custom_flags(libc::O_EXCL)
570            .open(device_path);
571        if let Err(e) = busy {
572            if e.raw_os_error() == Some(libc::EBUSY) {
573                return Err(format!(
574                    "Device '{device_path}' is already in use by another process.\n\
575                     Is another flash operation already running?"
576                ));
577            }
578            // Any other error (EPERM, EACCES) is fine here — we will handle
579            // it properly when we open for writing later.
580        }
581    }
582
583    // ── Step 1: Unmount ──────────────────────────────────────────────────────
584    send(tx, FlashEvent::Stage(FlashStage::Unmounting));
585    unmount_device(device_path, tx);
586
587    // ── Step 2: Write ────────────────────────────────────────────────────────
588    send(tx, FlashEvent::Stage(FlashStage::Writing));
589    send(
590        tx,
591        FlashEvent::Log(format!(
592            "Writing {image_size} bytes from {image_path} → {device_path}"
593        )),
594    );
595    write_image(image_path, device_path, image_size, tx, &cancel)?;
596
597    // ── Step 3: Sync ─────────────────────────────────────────────────────────
598    send(tx, FlashEvent::Stage(FlashStage::Syncing));
599    sync_device(device_path, tx);
600
601    // ── Step 4: Re-read partition table ──────────────────────────────────────
602    send(tx, FlashEvent::Stage(FlashStage::Rereading));
603    reread_partition_table(device_path, tx);
604
605    // ── Step 5: Verify ───────────────────────────────────────────────────────
606    send(tx, FlashEvent::Stage(FlashStage::Verifying));
607    verify(image_path, device_path, image_size, tx)?;
608
609    // ── Done ─────────────────────────────────────────────────────────────────
610    send(tx, FlashEvent::Done);
611    Ok(())
612}
613
614// ---------------------------------------------------------------------------
615// Partition-node guard (Linux only)
616// ---------------------------------------------------------------------------
617
618#[cfg(target_os = "linux")]
619fn reject_partition_node(device_path: &str) -> Result<(), String> {
620    let dev_name = Path::new(device_path)
621        .file_name()
622        .map(|n| n.to_string_lossy().to_string())
623        .unwrap_or_default();
624
625    let is_partition = {
626        let bytes = dev_name.as_bytes();
627        !bytes.is_empty() && bytes[bytes.len() - 1].is_ascii_digit() && {
628            let stem = dev_name.trim_end_matches(|c: char| c.is_ascii_digit());
629            stem.ends_with('p')
630                || (!stem.is_empty()
631                    && !stem.ends_with(|c: char| c.is_ascii_digit())
632                    && stem.chars().any(|c| c.is_ascii_alphabetic()))
633        }
634    };
635
636    if is_partition {
637        let whole = dev_name.trim_end_matches(|c: char| c.is_ascii_digit() || c == 'p');
638        return Err(format!(
639            "Refusing to write to partition node '{device_path}'. \
640             Select the whole-disk device (e.g. /dev/{whole}) instead."
641        ));
642    }
643
644    Ok(())
645}
646
647// ---------------------------------------------------------------------------
648// Privilege helpers
649// ---------------------------------------------------------------------------
650
651/// Open `device_path` for raw writing, temporarily escalating to root if the
652/// binary is setuid-root, then immediately dropping back to the real UID.
653fn open_device_for_writing(device_path: &str) -> Result<std::fs::File, String> {
654    #[cfg(unix)]
655    {
656        use nix::unistd::seteuid;
657
658        // Attempt to escalate to root.
659        //
660        // This only succeeds when the binary carries the setuid-root bit
661        // (`chmod u+s`).  If escalation fails we still try to open the file —
662        // it may be a regular writable file (e.g. during tests) or the user
663        // may already have write permission on the device.
664        let escalated = seteuid(nix::unistd::Uid::from_raw(0)).is_ok();
665
666        let result = std::fs::OpenOptions::new()
667            .write(true)
668            .open(device_path)
669            .map_err(|e| {
670                let raw = e.raw_os_error().unwrap_or(0);
671                if raw == libc::EACCES || raw == libc::EPERM {
672                    if escalated {
673                        format!(
674                            "Permission denied opening '{device_path}'.\n\
675                             Even with setuid-root the device refused access — \
676                             check that the device exists and is not in use."
677                        )
678                    } else {
679                        format!(
680                            "Permission denied opening '{device_path}'.\n\
681                             FlashKraft needs root access to write to block devices.\n\
682                             Install setuid-root so it can escalate automatically:\n\
683                             sudo chown root:root /usr/bin/flashkraft\n\
684                             sudo chmod u+s /usr/bin/flashkraft"
685                        )
686                    }
687                } else if raw == libc::EBUSY {
688                    format!(
689                        "Device '{device_path}' is busy. \
690                         Ensure all partitions are unmounted before flashing."
691                    )
692                } else {
693                    format!("Cannot open device '{device_path}' for writing: {e}")
694                }
695            });
696
697        // Drop back to the real (unprivileged) user immediately.
698        if escalated {
699            let _ = seteuid(real_uid());
700        }
701
702        result
703    }
704
705    #[cfg(not(unix))]
706    {
707        std::fs::OpenOptions::new()
708            .write(true)
709            .open(device_path)
710            .map_err(|e| {
711                let raw = e.raw_os_error().unwrap_or(0);
712                // ERROR_ACCESS_DENIED (5) or ERROR_PRIVILEGE_NOT_HELD (1314)
713                if raw == 5 || raw == 1314 {
714                    format!(
715                        "Access denied opening '{device_path}'.\n\
716                         FlashKraft must be run as Administrator on Windows.\n\
717                         Right-click the application and choose \
718                         'Run as administrator'."
719                    )
720                } else if raw == 32 {
721                    // ERROR_SHARING_VIOLATION
722                    format!(
723                        "Device '{device_path}' is in use by another process.\n\
724                         Close any applications using the drive and try again."
725                    )
726                } else {
727                    format!("Cannot open device '{device_path}' for writing: {e}")
728                }
729            })
730    }
731}
732
733// ---------------------------------------------------------------------------
734// Step 1 – Unmount
735// ---------------------------------------------------------------------------
736
737fn unmount_device(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
738    let device_name = Path::new(device_path)
739        .file_name()
740        .map(|n| n.to_string_lossy().to_string())
741        .unwrap_or_default();
742
743    let partitions = find_mounted_partitions(&device_name, device_path);
744
745    if partitions.is_empty() {
746        send(tx, FlashEvent::Log("No mounted partitions found".into()));
747    } else {
748        for partition in &partitions {
749            send(tx, FlashEvent::Log(format!("Unmounting {partition}")));
750            do_unmount(partition, tx);
751        }
752    }
753}
754
755/// Returns the list of mounted partitions/volumes that belong to `device_path`.
756///
757/// On Linux/macOS this parses `/proc/mounts`.
758/// On Windows this enumerates logical drive letters, resolves each to its
759/// underlying physical device via `QueryDosDeviceW`, and returns the volume
760/// paths (e.g. `\\.\C:`) whose physical device number matches `device_path`
761/// (e.g. `\\.\PhysicalDrive1`).
762fn find_mounted_partitions(
763    #[cfg_attr(target_os = "windows", allow(unused_variables))] device_name: &str,
764    device_path: &str,
765) -> Vec<String> {
766    #[cfg(not(target_os = "windows"))]
767    {
768        let mounts = std::fs::read_to_string("/proc/mounts")
769            .or_else(|_| std::fs::read_to_string("/proc/self/mounts"))
770            .unwrap_or_default();
771
772        let mut mount_points = Vec::new();
773        for line in mounts.lines() {
774            let mut fields = line.split_whitespace();
775            let dev = match fields.next() {
776                Some(d) => d,
777                None => continue,
778            };
779            // Second field in /proc/mounts is the mount point directory —
780            // that is what umount2 requires, not the device path.
781            let mount_point = match fields.next() {
782                Some(m) => m,
783                None => continue,
784            };
785            if dev == device_path || is_partition_of(dev, device_name) {
786                mount_points.push(mount_point.to_string());
787            }
788        }
789        mount_points
790    }
791
792    #[cfg(target_os = "windows")]
793    {
794        windows::find_volumes_on_physical_drive(device_path)
795    }
796}
797
798#[cfg(not(target_os = "windows"))]
799fn is_partition_of(dev: &str, device_name: &str) -> bool {
800    // `dev` may be a full path like "/dev/sda1"; compare only the basename.
801    let dev_base = Path::new(dev)
802        .file_name()
803        .map(|n| n.to_string_lossy())
804        .unwrap_or_default();
805
806    if !dev_base.starts_with(device_name) {
807        return false;
808    }
809    let suffix = &dev_base[device_name.len()..];
810    if suffix.is_empty() {
811        return false;
812    }
813    let first = suffix.chars().next().unwrap();
814    first.is_ascii_digit() || (first == 'p' && suffix.len() > 1)
815}
816
817/// Return `true` if `name` resolves to an executable on `PATH`.
818/// Used by `do_unmount` to prefer `udisksctl` when available.
819#[cfg(target_os = "linux")]
820fn which_exists(name: &str) -> bool {
821    use std::os::unix::fs::PermissionsExt;
822    std::env::var("PATH")
823        .unwrap_or_default()
824        .split(':')
825        .any(|dir| {
826            let p = std::path::Path::new(dir).join(name);
827            std::fs::metadata(&p)
828                .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
829                .unwrap_or(false)
830        })
831}
832
833fn do_unmount(partition: &str, tx: &mpsc::Sender<FlashEvent>) {
834    #[cfg(target_os = "linux")]
835    {
836        use nix::unistd::seteuid;
837        use std::ffi::CString;
838
839        // ── Strategy 1: udisksctl ─────────────────────────────────────────────
840        // Prefer udisksctl when available — it signals udisks2/systemd-mount to
841        // properly release the mount and prevents the automounter from
842        // immediately re-mounting the partition after we detach it.
843        // `--no-user-interaction` prevents it from blocking on a password prompt.
844        if which_exists("udisksctl") {
845            // Spawn with a timeout — udisksctl can stall if udisks2 is busy.
846            // We give it 5 seconds before falling through to umount2.
847            let result = std::process::Command::new("udisksctl")
848                .args(["unmount", "--no-user-interaction", "-b", partition])
849                .stdout(std::process::Stdio::null())
850                .stderr(std::process::Stdio::null())
851                .spawn();
852
853            let udisks_ok = match result {
854                Ok(mut child) => {
855                    // Poll for up to 5 s in 100 ms increments.
856                    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
857                    loop {
858                        match child.try_wait() {
859                            Ok(Some(status)) => break status.success(),
860                            Ok(None) if std::time::Instant::now() < deadline => {
861                                std::thread::sleep(std::time::Duration::from_millis(100));
862                            }
863                            _ => {
864                                // Timed out or error — kill and fall through.
865                                let _ = child.kill();
866                                send(
867                                    tx,
868                                    FlashEvent::Log(
869                                        "udisksctl timed out — falling back to umount2".into(),
870                                    ),
871                                );
872                                break false;
873                            }
874                        }
875                    }
876                }
877                Err(_) => false,
878            };
879
880            if udisks_ok {
881                send(
882                    tx,
883                    FlashEvent::Log(format!("Unmounted {partition} via udisksctl")),
884                );
885                return;
886            }
887        }
888
889        // ── Strategy 2: umount2 MNT_DETACH (fallback) ────────────────────────
890        // Lazy unmount: detaches the filesystem immediately even if busy.
891        // umount2 never blocks — MNT_DETACH returns right away.
892        let _ = seteuid(nix::unistd::Uid::from_raw(0));
893
894        if let Ok(c_path) = CString::new(partition) {
895            let ret = unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) };
896            if ret != 0 {
897                let raw = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
898                match raw {
899                    // EINVAL — not a mount point or already unmounted, harmless.
900                    libc::EINVAL => {}
901                    // ENOENT — path doesn't exist, also harmless.
902                    libc::ENOENT => {}
903                    _ => {
904                        let err = std::io::Error::from_raw_os_error(raw);
905                        send(
906                            tx,
907                            FlashEvent::Log(format!(
908                                "Warning — could not unmount {partition}: {err}"
909                            )),
910                        );
911                    }
912                }
913            }
914        }
915
916        let _ = seteuid(real_uid());
917    }
918
919    #[cfg(target_os = "macos")]
920    {
921        let out = std::process::Command::new("diskutil")
922            .args(["unmount", partition])
923            .output();
924        if let Ok(o) = out {
925            if !o.status.success() {
926                send(
927                    tx,
928                    FlashEvent::Log(format!("Warning — diskutil unmount {partition} failed")),
929                );
930            }
931        }
932    }
933
934    // Windows: open the volume with exclusive access, lock it, then dismount.
935    // The volume path is expected to be of the form `\\.\C:` (no trailing slash).
936    #[cfg(target_os = "windows")]
937    {
938        match windows::lock_and_dismount_volume(partition) {
939            Ok(()) => send(
940                tx,
941                FlashEvent::Log(format!("Dismounted volume {partition}")),
942            ),
943            Err(e) => send(
944                tx,
945                FlashEvent::Log(format!("Warning — could not dismount {partition}: {e}")),
946            ),
947        }
948    }
949}
950
951// ---------------------------------------------------------------------------
952// Step 2 – Write image
953// ---------------------------------------------------------------------------
954
955fn write_image(
956    image_path: &str,
957    device_path: &str,
958    image_size: u64,
959    tx: &mpsc::Sender<FlashEvent>,
960    cancel: &Arc<AtomicBool>,
961) -> Result<(), String> {
962    let image_file =
963        std::fs::File::open(image_path).map_err(|e| format!("Cannot open image: {e}"))?;
964
965    let device_file = open_device_for_writing(device_path)?;
966
967    let mut reader = io::BufReader::with_capacity(BLOCK_SIZE, image_file);
968    let mut writer = io::BufWriter::with_capacity(BLOCK_SIZE, device_file);
969    let mut buf = vec![0u8; BLOCK_SIZE];
970
971    let mut bytes_written: u64 = 0;
972    let start = Instant::now();
973    let mut last_report = Instant::now();
974
975    loop {
976        // Honour cancellation requests between blocks.
977        if cancel.load(Ordering::SeqCst) {
978            return Err("Flash operation cancelled by user".to_string());
979        }
980
981        let n = reader
982            .read(&mut buf)
983            .map_err(|e| format!("Read error on image: {e}"))?;
984
985        if n == 0 {
986            break; // EOF
987        }
988
989        writer
990            .write_all(&buf[..n])
991            .map_err(|e| format!("Write error on device: {e}"))?;
992
993        bytes_written += n as u64;
994
995        let now = Instant::now();
996        if now.duration_since(last_report) >= PROGRESS_INTERVAL || bytes_written >= image_size {
997            let elapsed_s = now.duration_since(start).as_secs_f32();
998            let speed_mb_s = if elapsed_s > 0.001 {
999                (bytes_written as f32 / (1024.0 * 1024.0)) / elapsed_s
1000            } else {
1001                0.0
1002            };
1003
1004            send(
1005                tx,
1006                FlashEvent::Progress {
1007                    bytes_written,
1008                    total_bytes: image_size,
1009                    speed_mb_s,
1010                },
1011            );
1012            last_report = now;
1013        }
1014    }
1015
1016    // Flush BufWriter → kernel page cache.
1017    writer
1018        .flush()
1019        .map_err(|e| format!("Buffer flush error: {e}"))?;
1020
1021    // Retrieve the underlying File for fsync.
1022    #[cfg_attr(not(unix), allow(unused_variables))]
1023    let device_file = writer
1024        .into_inner()
1025        .map_err(|e| format!("BufWriter error: {e}"))?;
1026
1027    // fsync: push all dirty pages to the physical medium.
1028    // Treated as a hard error — a failed fsync means we cannot trust the
1029    // data reached the device.
1030    #[cfg(unix)]
1031    {
1032        use std::os::unix::io::AsRawFd;
1033        let fd = device_file.as_raw_fd();
1034        let ret = unsafe { libc::fsync(fd) };
1035        if ret != 0 {
1036            let err = std::io::Error::last_os_error();
1037            return Err(format!(
1038                "fsync failed on '{device_path}': {err} — \
1039                 data may not have been fully written to the device"
1040            ));
1041        }
1042    }
1043
1044    // Emit a final progress event at 100 %.
1045    let elapsed_s = start.elapsed().as_secs_f32();
1046    let speed_mb_s = if elapsed_s > 0.001 {
1047        (bytes_written as f32 / (1024.0 * 1024.0)) / elapsed_s
1048    } else {
1049        0.0
1050    };
1051    send(
1052        tx,
1053        FlashEvent::Progress {
1054            bytes_written,
1055            total_bytes: image_size,
1056            speed_mb_s,
1057        },
1058    );
1059
1060    send(tx, FlashEvent::Log("Image write complete".into()));
1061    Ok(())
1062}
1063
1064// ---------------------------------------------------------------------------
1065// Step 3 – Sync
1066// ---------------------------------------------------------------------------
1067
1068fn sync_device(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1069    #[cfg(unix)]
1070    if let Ok(f) = std::fs::OpenOptions::new().write(true).open(device_path) {
1071        use std::os::unix::io::AsRawFd;
1072        let fd = f.as_raw_fd();
1073        #[cfg(target_os = "linux")]
1074        unsafe {
1075            libc::fdatasync(fd);
1076        }
1077        #[cfg(not(target_os = "linux"))]
1078        unsafe {
1079            libc::fsync(fd);
1080        }
1081        drop(f);
1082    }
1083
1084    #[cfg(target_os = "linux")]
1085    unsafe {
1086        libc::sync();
1087    }
1088
1089    // Windows: open the physical drive and call FlushFileBuffers.
1090    // This forces the OS to flush all dirty pages for the device to hardware.
1091    #[cfg(target_os = "windows")]
1092    {
1093        match windows::flush_device_buffers(device_path) {
1094            Ok(()) => {}
1095            Err(e) => send(
1096                tx,
1097                FlashEvent::Log(format!(
1098                    "Warning — FlushFileBuffers on '{device_path}' failed: {e}"
1099                )),
1100            ),
1101        }
1102    }
1103
1104    send(tx, FlashEvent::Log("Write-back caches flushed".into()));
1105}
1106
1107// ---------------------------------------------------------------------------
1108// Step 4 – Re-read partition table
1109// ---------------------------------------------------------------------------
1110
1111#[cfg(target_os = "linux")]
1112fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1113    use nix::ioctl_none;
1114    use std::os::unix::io::AsRawFd;
1115
1116    ioctl_none!(blkrrpart, 0x12, 95);
1117
1118    // Brief pause so any pending I/O completes before we poke the kernel.
1119    std::thread::sleep(Duration::from_millis(500));
1120
1121    match std::fs::OpenOptions::new().write(true).open(device_path) {
1122        Ok(f) => {
1123            let result = unsafe { blkrrpart(f.as_raw_fd()) };
1124            match result {
1125                Ok(_) => send(
1126                    tx,
1127                    FlashEvent::Log("Kernel partition table refreshed".into()),
1128                ),
1129                Err(e) => send(
1130                    tx,
1131                    FlashEvent::Log(format!(
1132                        "Warning — BLKRRPART ioctl failed \
1133                         (device may not be partitioned): {e}"
1134                    )),
1135                ),
1136            }
1137        }
1138        Err(e) => send(
1139            tx,
1140            FlashEvent::Log(format!(
1141                "Warning — could not open device for BLKRRPART: {e}"
1142            )),
1143        ),
1144    }
1145}
1146
1147#[cfg(target_os = "macos")]
1148fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1149    let _ = std::process::Command::new("diskutil")
1150        .args(["rereadPartitionTable", device_path])
1151        .output();
1152    send(
1153        tx,
1154        FlashEvent::Log("Partition table refresh requested (macOS)".into()),
1155    );
1156}
1157
1158// Windows: IOCTL_DISK_UPDATE_PROPERTIES asks the partition manager to
1159// re-enumerate the partition table from the on-disk data.
1160#[cfg(target_os = "windows")]
1161fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1162    // Brief pause so the OS flushes before we poke the partition manager.
1163    std::thread::sleep(Duration::from_millis(500));
1164
1165    match windows::update_disk_properties(device_path) {
1166        Ok(()) => send(
1167            tx,
1168            FlashEvent::Log("Partition table refreshed (IOCTL_DISK_UPDATE_PROPERTIES)".into()),
1169        ),
1170        Err(e) => send(
1171            tx,
1172            FlashEvent::Log(format!(
1173                "Warning — IOCTL_DISK_UPDATE_PROPERTIES failed: {e}"
1174            )),
1175        ),
1176    }
1177}
1178
1179#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1180fn reread_partition_table(_device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1181    send(
1182        tx,
1183        FlashEvent::Log("Partition table refresh not supported on this platform".into()),
1184    );
1185}
1186
1187// ---------------------------------------------------------------------------
1188// Step 5 – Verify
1189// ---------------------------------------------------------------------------
1190
1191fn verify(
1192    image_path: &str,
1193    device_path: &str,
1194    image_size: u64,
1195    tx: &mpsc::Sender<FlashEvent>,
1196) -> Result<(), String> {
1197    send(
1198        tx,
1199        FlashEvent::Log("Computing SHA-256 of source image".into()),
1200    );
1201    let image_hash = sha256_with_progress(image_path, image_size, "image", tx)?;
1202
1203    send(
1204        tx,
1205        FlashEvent::Log(format!(
1206            "Reading back {image_size} bytes from device for verification"
1207        )),
1208    );
1209    let device_hash = sha256_with_progress(device_path, image_size, "device", tx)?;
1210
1211    if image_hash != device_hash {
1212        return Err(format!(
1213            "Verification failed — data mismatch \
1214             (image={image_hash} device={device_hash})"
1215        ));
1216    }
1217
1218    send(
1219        tx,
1220        FlashEvent::Log(format!("Verification passed ({image_hash})")),
1221    );
1222    Ok(())
1223}
1224
1225/// Compute the SHA-256 digest of the first `max_bytes` of `path`, emitting
1226/// [`FlashEvent::VerifyProgress`] events at [`PROGRESS_INTERVAL`] intervals.
1227///
1228/// `phase` is forwarded verbatim into every `VerifyProgress` event so the
1229/// UI can distinguish the image-hash pass (`"image"`) from the device
1230/// read-back pass (`"device"`).
1231fn sha256_with_progress(
1232    path: &str,
1233    max_bytes: u64,
1234    phase: &'static str,
1235    tx: &mpsc::Sender<FlashEvent>,
1236) -> Result<String, String> {
1237    use sha2::{Digest, Sha256};
1238
1239    let file =
1240        std::fs::File::open(path).map_err(|e| format!("Cannot open {path} for hashing: {e}"))?;
1241
1242    let mut hasher = Sha256::new();
1243    let mut reader = io::BufReader::with_capacity(BLOCK_SIZE, file);
1244    let mut buf = vec![0u8; BLOCK_SIZE];
1245    let mut remaining = max_bytes;
1246    let mut bytes_read: u64 = 0;
1247
1248    let start = Instant::now();
1249    let mut last_report = Instant::now();
1250
1251    while remaining > 0 {
1252        let to_read = (remaining as usize).min(buf.len());
1253        let n = reader
1254            .read(&mut buf[..to_read])
1255            .map_err(|e| format!("Read error while hashing {path}: {e}"))?;
1256        if n == 0 {
1257            break;
1258        }
1259        hasher.update(&buf[..n]);
1260        bytes_read += n as u64;
1261        remaining -= n as u64;
1262
1263        let now = Instant::now();
1264        if now.duration_since(last_report) >= PROGRESS_INTERVAL || remaining == 0 {
1265            let elapsed_s = now.duration_since(start).as_secs_f32();
1266            let speed_mb_s = if elapsed_s > 0.001 {
1267                (bytes_read as f32 / (1024.0 * 1024.0)) / elapsed_s
1268            } else {
1269                0.0
1270            };
1271            send(
1272                tx,
1273                FlashEvent::VerifyProgress {
1274                    phase,
1275                    bytes_read,
1276                    total_bytes: max_bytes,
1277                    speed_mb_s,
1278                },
1279            );
1280            last_report = now;
1281        }
1282    }
1283
1284    Ok(format!("{:x}", hasher.finalize()))
1285}
1286
1287/// Legacy non-progress variant kept for unit tests that don't need a channel.
1288#[cfg(test)]
1289fn sha256_first_n_bytes(path: &str, max_bytes: u64) -> Result<String, String> {
1290    let (tx, _rx) = mpsc::channel();
1291    sha256_with_progress(path, max_bytes, "image", &tx)
1292}
1293
1294// ---------------------------------------------------------------------------
1295// Windows implementation helpers
1296// ---------------------------------------------------------------------------
1297
1298/// All Windows-specific raw-device operations are collected here.
1299///
1300/// ## Privilege
1301/// The binary must be run as Administrator (the UAC manifest embedded by
1302/// `build.rs` ensures Windows prompts for elevation on launch).  Raw physical
1303/// drive access (`\\.\PhysicalDriveN`) and volume lock/dismount both require
1304/// the `SeManageVolumePrivilege` that is only present in an elevated token.
1305///
1306/// ## Volume vs physical drive paths
1307/// - **Physical drive**: `\\.\PhysicalDrive0`, `\\.\PhysicalDrive1`, …
1308///   Used for writing the image, flushing, and partition-table refresh.
1309/// - **Volume (drive letter)**: `\\.\C:`, `\\.\D:`, …
1310///   Used for locking and dismounting before we write.
1311#[cfg(target_os = "windows")]
1312mod windows {
1313    // ── Win32 type aliases ────────────────────────────────────────────────────
1314    // windows-sys uses raw C types; give them readable names.
1315    use windows_sys::Win32::{
1316        Foundation::{
1317            CloseHandle, FALSE, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
1318        },
1319        Storage::FileSystem::{
1320            CreateFileW, FlushFileBuffers, FILE_FLAG_WRITE_THROUGH, FILE_SHARE_READ,
1321            FILE_SHARE_WRITE, OPEN_EXISTING,
1322        },
1323        System::{
1324            Ioctl::{FSCTL_DISMOUNT_VOLUME, FSCTL_LOCK_VOLUME, IOCTL_DISK_UPDATE_PROPERTIES},
1325            IO::DeviceIoControl,
1326        },
1327    };
1328
1329    // ── Helpers ───────────────────────────────────────────────────────────────
1330
1331    /// Encode a Rust `&str` as a null-terminated UTF-16 `Vec<u16>`.
1332    fn to_wide(s: &str) -> Vec<u16> {
1333        use std::os::windows::ffi::OsStrExt;
1334        std::ffi::OsStr::new(s)
1335            .encode_wide()
1336            .chain(std::iter::once(0))
1337            .collect()
1338    }
1339
1340    /// Open a device path (`\\.\PhysicalDriveN` or `\\.\C:`) and return its
1341    /// Win32 `HANDLE`.  The handle must be closed with `CloseHandle` when done.
1342    ///
1343    /// `access` should be `GENERIC_READ`, `GENERIC_WRITE`, or both OR-ed.
1344    fn open_device_handle(path: &str, access: u32) -> Result<HANDLE, String> {
1345        let wide = to_wide(path);
1346        let handle = unsafe {
1347            CreateFileW(
1348                wide.as_ptr(),
1349                access,
1350                FILE_SHARE_READ | FILE_SHARE_WRITE,
1351                std::ptr::null(),
1352                OPEN_EXISTING,
1353                FILE_FLAG_WRITE_THROUGH,
1354                std::ptr::null_mut(),
1355            )
1356        };
1357        if handle == INVALID_HANDLE_VALUE {
1358            Err(format!(
1359                "Cannot open device '{}': {}",
1360                path,
1361                std::io::Error::last_os_error()
1362            ))
1363        } else {
1364            Ok(handle)
1365        }
1366    }
1367
1368    /// Issue a simple `DeviceIoControl` call with no input or output buffer.
1369    ///
1370    /// Returns `Ok(())` on success, or an `Err` with the Win32 error message.
1371    fn device_ioctl(handle: HANDLE, code: u32) -> Result<(), String> {
1372        let mut bytes_returned: u32 = 0;
1373        let ok = unsafe {
1374            DeviceIoControl(
1375                handle,
1376                code,
1377                std::ptr::null(), // no input buffer
1378                0,
1379                std::ptr::null_mut(), // no output buffer
1380                0,
1381                &mut bytes_returned,
1382                std::ptr::null_mut(), // synchronous (no OVERLAPPED)
1383            )
1384        };
1385        if ok == FALSE {
1386            Err(format!("{}", std::io::Error::last_os_error()))
1387        } else {
1388            Ok(())
1389        }
1390    }
1391
1392    // ── Public helpers called from flash_helper ───────────────────────────────
1393
1394    /// Enumerate all logical drive letters whose underlying physical device
1395    /// path matches `physical_drive` (e.g. `\\.\PhysicalDrive1`).
1396    ///
1397    /// Returns a list of volume paths suitable for passing to
1398    /// `lock_and_dismount_volume`, e.g. `["\\.\C:", "\\.\D:"]`.
1399    ///
1400    /// Algorithm:
1401    /// 1. Obtain the physical drive number from `physical_drive`.
1402    /// 2. Call `GetLogicalDriveStringsW` to list all drive letters.
1403    /// 3. For each letter, open the volume and call `IOCTL_STORAGE_GET_DEVICE_NUMBER`
1404    ///    to get its physical drive number.
1405    /// 4. Collect those whose number matches.
1406    pub fn find_volumes_on_physical_drive(physical_drive: &str) -> Vec<String> {
1407        use windows_sys::Win32::{
1408            Storage::FileSystem::GetLogicalDriveStringsW,
1409            System::Ioctl::{IOCTL_STORAGE_GET_DEVICE_NUMBER, STORAGE_DEVICE_NUMBER},
1410        };
1411
1412        // Extract the drive index from "\\.\PhysicalDriveN".
1413        let target_index: u32 = physical_drive
1414            .to_ascii_lowercase()
1415            .trim_start_matches(r"\\.\physicaldrive")
1416            .parse()
1417            .unwrap_or(u32::MAX);
1418
1419        // Get all logical drive strings ("C:\", "D:\", …).
1420        let mut buf = vec![0u16; 512];
1421        let len = unsafe { GetLogicalDriveStringsW(buf.len() as u32, buf.as_mut_ptr()) };
1422        if len == 0 || len > buf.len() as u32 {
1423            return Vec::new();
1424        }
1425
1426        // Parse the null-separated, double-null-terminated list.
1427        let drive_letters: Vec<String> = buf[..len as usize]
1428            .split(|&c| c == 0)
1429            .filter(|s| !s.is_empty())
1430            .map(|s| {
1431                // "C:\" → "\\.\C:"  (no trailing backslash — required for
1432                // CreateFileW on a volume)
1433                let letter: String = std::char::from_u32(s[0] as u32)
1434                    .map(|c| c.to_string())
1435                    .unwrap_or_default();
1436                format!(r"\\.\{}:", letter)
1437            })
1438            .collect();
1439
1440        let mut matching = Vec::new();
1441
1442        for vol_path in &drive_letters {
1443            let wide = to_wide(vol_path);
1444            let handle = unsafe {
1445                CreateFileW(
1446                    wide.as_ptr(),
1447                    GENERIC_READ,
1448                    FILE_SHARE_READ | FILE_SHARE_WRITE,
1449                    std::ptr::null(),
1450                    OPEN_EXISTING,
1451                    0,
1452                    std::ptr::null_mut(),
1453                )
1454            };
1455            if handle == INVALID_HANDLE_VALUE {
1456                continue;
1457            }
1458
1459            let mut dev_num = STORAGE_DEVICE_NUMBER {
1460                DeviceType: 0,
1461                DeviceNumber: u32::MAX,
1462                PartitionNumber: 0,
1463            };
1464            let mut bytes_returned: u32 = 0;
1465
1466            let ok = unsafe {
1467                DeviceIoControl(
1468                    handle,
1469                    IOCTL_STORAGE_GET_DEVICE_NUMBER,
1470                    std::ptr::null(),
1471                    0,
1472                    &mut dev_num as *mut _ as *mut _,
1473                    std::mem::size_of::<STORAGE_DEVICE_NUMBER>() as u32,
1474                    &mut bytes_returned,
1475                    std::ptr::null_mut(),
1476                )
1477            };
1478
1479            unsafe { CloseHandle(handle) };
1480
1481            if ok != FALSE && dev_num.DeviceNumber == target_index {
1482                matching.push(vol_path.clone());
1483            }
1484        }
1485
1486        matching
1487    }
1488
1489    /// Lock a volume exclusively and dismount it so writes to the underlying
1490    /// physical disk can proceed without the filesystem intercepting I/O.
1491    ///
1492    /// Steps (mirrors what Rufus / dd for Windows do):
1493    /// 1. Open the volume with `GENERIC_READ | GENERIC_WRITE`.
1494    /// 2. `FSCTL_LOCK_VOLUME`   — exclusive lock; fails if files are open.
1495    /// 3. `FSCTL_DISMOUNT_VOLUME` — tell the FS driver to flush and detach.
1496    ///
1497    /// The lock is held for the lifetime of the handle.  Because we close the
1498    /// handle immediately after dismounting, the volume is automatically
1499    /// unlocked (`FSCTL_UNLOCK_VOLUME` is implicit on handle close).
1500    pub fn lock_and_dismount_volume(volume_path: &str) -> Result<(), String> {
1501        let handle = open_device_handle(volume_path, GENERIC_READ | GENERIC_WRITE)?;
1502
1503        // Lock — exclusive; if this fails (files open) we still try to
1504        // dismount because the user may have opened Explorer on the drive.
1505        let lock_result = device_ioctl(handle, FSCTL_LOCK_VOLUME);
1506        if let Err(ref e) = lock_result {
1507            // Non-fatal: log and continue.  Dismount can still succeed.
1508            eprintln!(
1509                "[flash] FSCTL_LOCK_VOLUME on '{volume_path}' failed ({e}); \
1510                 attempting dismount anyway"
1511            );
1512        }
1513
1514        // Dismount — detaches the filesystem; flushes dirty data first.
1515        let dismount_result = device_ioctl(handle, FSCTL_DISMOUNT_VOLUME);
1516
1517        unsafe { CloseHandle(handle) };
1518
1519        lock_result.and(dismount_result)
1520    }
1521
1522    /// Call `FlushFileBuffers` on the physical drive to force the OS to push
1523    /// all dirty write-back pages to the device hardware.
1524    pub fn flush_device_buffers(device_path: &str) -> Result<(), String> {
1525        let handle = open_device_handle(device_path, GENERIC_WRITE)?;
1526        let ok = unsafe { FlushFileBuffers(handle) };
1527        unsafe { CloseHandle(handle) };
1528        if ok == FALSE {
1529            Err(format!("{}", std::io::Error::last_os_error()))
1530        } else {
1531            Ok(())
1532        }
1533    }
1534
1535    /// Send `IOCTL_DISK_UPDATE_PROPERTIES` to the physical drive, asking the
1536    /// Windows partition manager to re-read the partition table from disk.
1537    pub fn update_disk_properties(device_path: &str) -> Result<(), String> {
1538        let handle = open_device_handle(device_path, GENERIC_READ | GENERIC_WRITE)?;
1539        let result = device_ioctl(handle, IOCTL_DISK_UPDATE_PROPERTIES);
1540        unsafe { CloseHandle(handle) };
1541        result
1542    }
1543
1544    // ── Unit tests ────────────────────────────────────────────────────────────
1545
1546    #[cfg(test)]
1547    mod tests {
1548        use super::*;
1549
1550        /// `to_wide` must produce a null-terminated UTF-16 sequence.
1551        #[test]
1552        fn test_to_wide_null_terminated() {
1553            let wide = to_wide("ABC");
1554            assert_eq!(wide.last(), Some(&0u16), "must be null-terminated");
1555            assert_eq!(&wide[..3], &[b'A' as u16, b'B' as u16, b'C' as u16]);
1556        }
1557
1558        /// `to_wide` on an empty string produces exactly one null.
1559        #[test]
1560        fn test_to_wide_empty() {
1561            let wide = to_wide("");
1562            assert_eq!(wide, vec![0u16]);
1563        }
1564
1565        /// `open_device_handle` on a nonexistent path must return an error.
1566        #[test]
1567        fn test_open_device_handle_bad_path_returns_error() {
1568            let result = open_device_handle(r"\\.\NonExistentDevice999", GENERIC_READ);
1569            assert!(result.is_err(), "expected error for nonexistent device");
1570        }
1571
1572        /// `flush_device_buffers` on a nonexistent drive must return an error.
1573        #[test]
1574        fn test_flush_device_buffers_bad_path() {
1575            let result = flush_device_buffers(r"\\.\PhysicalDrive999");
1576            assert!(result.is_err());
1577        }
1578
1579        /// `update_disk_properties` on a nonexistent drive must return an error.
1580        #[test]
1581        fn test_update_disk_properties_bad_path() {
1582            let result = update_disk_properties(r"\\.\PhysicalDrive999");
1583            assert!(result.is_err());
1584        }
1585
1586        /// `lock_and_dismount_volume` on a nonexistent path must return an error.
1587        #[test]
1588        fn test_lock_and_dismount_bad_path() {
1589            let result = lock_and_dismount_volume(r"\\.\Z99:");
1590            assert!(result.is_err());
1591        }
1592
1593        /// `find_volumes_on_physical_drive` with an unparseable path should
1594        /// return an empty Vec (no panic).
1595        #[test]
1596        fn test_find_volumes_bad_path_no_panic() {
1597            let result = find_volumes_on_physical_drive("not-a-valid-path");
1598            // May be empty or contain volumes; must not panic.
1599            let _ = result;
1600        }
1601
1602        /// `find_volumes_on_physical_drive` for a very high drive number
1603        /// (almost certainly nonexistent) should return an empty list.
1604        #[test]
1605        fn test_find_volumes_nonexistent_drive_returns_empty() {
1606            let result = find_volumes_on_physical_drive(r"\\.\PhysicalDrive999");
1607            assert!(
1608                result.is_empty(),
1609                "expected no volumes for PhysicalDrive999"
1610            );
1611        }
1612    }
1613}
1614
1615// ---------------------------------------------------------------------------
1616// Tests
1617// ---------------------------------------------------------------------------
1618
1619#[cfg(test)]
1620mod tests {
1621    use super::*;
1622    use std::io::Write;
1623    use std::sync::mpsc;
1624
1625    fn make_channel() -> (mpsc::Sender<FlashEvent>, mpsc::Receiver<FlashEvent>) {
1626        mpsc::channel()
1627    }
1628
1629    fn drain(rx: &mpsc::Receiver<FlashEvent>) -> Vec<FlashEvent> {
1630        let mut events = Vec::new();
1631        while let Ok(e) = rx.try_recv() {
1632            events.push(e);
1633        }
1634        events
1635    }
1636
1637    fn has_stage(events: &[FlashEvent], stage: &FlashStage) -> bool {
1638        events
1639            .iter()
1640            .any(|e| matches!(e, FlashEvent::Stage(s) if s == stage))
1641    }
1642
1643    fn find_error(events: &[FlashEvent]) -> Option<&str> {
1644        events.iter().find_map(|e| {
1645            if let FlashEvent::Error(msg) = e {
1646                Some(msg.as_str())
1647            } else {
1648                None
1649            }
1650        })
1651    }
1652
1653    // ── set_real_uid ────────────────────────────────────────────────────────
1654
1655    #[test]
1656    fn test_is_privileged_returns_bool() {
1657        // Just verify it doesn't panic and returns a consistent value.
1658        let first = is_privileged();
1659        let second = is_privileged();
1660        assert_eq!(first, second, "is_privileged must be deterministic");
1661    }
1662
1663    #[test]
1664    fn test_reexec_as_root_does_not_panic_when_already_escalated() {
1665        // With the guard env-var set, reexec_as_root must return immediately
1666        // without panicking or actually exec-ing anything.
1667        std::env::set_var("FLASHKRAFT_ESCALATED", "1");
1668        reexec_as_root(); // must not exec — guard fires immediately
1669        std::env::remove_var("FLASHKRAFT_ESCALATED");
1670    }
1671
1672    #[test]
1673    fn test_set_real_uid_stores_value() {
1674        // OnceLock only sets once; in tests the first call wins.
1675        // Just verify it doesn't panic.
1676        set_real_uid(1000);
1677    }
1678
1679    // ── is_partition_of ─────────────────────────────────────────────────────
1680
1681    #[test]
1682    #[cfg(not(target_os = "windows"))]
1683    fn test_is_partition_of_sda() {
1684        assert!(is_partition_of("/dev/sda1", "sda"));
1685        assert!(is_partition_of("/dev/sda2", "sda"));
1686        assert!(!is_partition_of("/dev/sdb1", "sda"));
1687        assert!(!is_partition_of("/dev/sda", "sda"));
1688    }
1689
1690    #[test]
1691    #[cfg(not(target_os = "windows"))]
1692    fn test_is_partition_of_nvme() {
1693        assert!(is_partition_of("/dev/nvme0n1p1", "nvme0n1"));
1694        assert!(is_partition_of("/dev/nvme0n1p2", "nvme0n1"));
1695        assert!(!is_partition_of("/dev/nvme0n1", "nvme0n1"));
1696    }
1697
1698    #[test]
1699    #[cfg(not(target_os = "windows"))]
1700    fn test_is_partition_of_mmcblk() {
1701        assert!(is_partition_of("/dev/mmcblk0p1", "mmcblk0"));
1702        assert!(!is_partition_of("/dev/mmcblk0", "mmcblk0"));
1703    }
1704
1705    #[test]
1706    #[cfg(not(target_os = "windows"))]
1707    fn test_is_partition_of_no_false_prefix_match() {
1708        assert!(!is_partition_of("/dev/sda1", "sd"));
1709    }
1710
1711    // ── reject_partition_node ───────────────────────────────────────────────
1712
1713    #[test]
1714    #[cfg(target_os = "linux")]
1715    fn test_reject_partition_node_sda1() {
1716        let dir = std::env::temp_dir();
1717        let img = dir.join("fk_reject_img.bin");
1718        std::fs::write(&img, vec![0u8; 1024]).unwrap();
1719
1720        let result = reject_partition_node("/dev/sda1");
1721        assert!(result.is_err());
1722        assert!(result.unwrap_err().contains("Refusing"));
1723
1724        let _ = std::fs::remove_file(img);
1725    }
1726
1727    #[test]
1728    #[cfg(target_os = "linux")]
1729    fn test_reject_partition_node_nvme() {
1730        let result = reject_partition_node("/dev/nvme0n1p1");
1731        assert!(result.is_err());
1732        assert!(result.unwrap_err().contains("Refusing"));
1733    }
1734
1735    #[test]
1736    #[cfg(target_os = "linux")]
1737    fn test_reject_partition_node_accepts_whole_disk() {
1738        // /dev/sdb does not exist in CI — the function only checks the name
1739        // pattern, not whether the path exists.
1740        let result = reject_partition_node("/dev/sdb");
1741        assert!(result.is_ok(), "whole-disk node should not be rejected");
1742    }
1743
1744    // ── find_mounted_partitions ──────────────────────────────────────────────
1745
1746    #[test]
1747    fn test_find_mounted_partitions_parses_proc_mounts_format() {
1748        // We cannot mock /proc/mounts in a unit test so we just verify the
1749        // function doesn't panic and returns a Vec.
1750        let result = find_mounted_partitions("sda", "/dev/sda");
1751        let _ = result; // any result is valid
1752    }
1753
1754    // ── sha256_first_n_bytes ─────────────────────────────────────────────────
1755
1756    #[test]
1757    fn test_sha256_full_file() {
1758        use sha2::{Digest, Sha256};
1759
1760        let dir = std::env::temp_dir();
1761        let path = dir.join("fk_sha256_full.bin");
1762        let data: Vec<u8> = (0u8..=255u8).cycle().take(4096).collect();
1763        std::fs::write(&path, &data).unwrap();
1764
1765        let result = sha256_first_n_bytes(path.to_str().unwrap(), data.len() as u64).unwrap();
1766        let expected = format!("{:x}", Sha256::digest(&data));
1767        assert_eq!(result, expected);
1768
1769        let _ = std::fs::remove_file(path);
1770    }
1771
1772    #[test]
1773    fn test_sha256_partial() {
1774        use sha2::{Digest, Sha256};
1775
1776        let dir = std::env::temp_dir();
1777        let path = dir.join("fk_sha256_partial.bin");
1778        let data: Vec<u8> = (0u8..=255u8).cycle().take(8192).collect();
1779        std::fs::write(&path, &data).unwrap();
1780
1781        let n = 4096u64;
1782        let result = sha256_first_n_bytes(path.to_str().unwrap(), n).unwrap();
1783        let expected = format!("{:x}", Sha256::digest(&data[..n as usize]));
1784        assert_eq!(result, expected);
1785
1786        let _ = std::fs::remove_file(path);
1787    }
1788
1789    #[test]
1790    fn test_sha256_nonexistent_returns_error() {
1791        let result = sha256_first_n_bytes("/nonexistent/path.bin", 1024);
1792        assert!(result.is_err());
1793        assert!(result.unwrap_err().contains("Cannot open"));
1794    }
1795
1796    #[test]
1797    fn test_sha256_empty_read_is_hash_of_empty() {
1798        use sha2::{Digest, Sha256};
1799
1800        let dir = std::env::temp_dir();
1801        let path = dir.join("fk_sha256_empty.bin");
1802        std::fs::write(&path, b"hello world extended data").unwrap();
1803
1804        // max_bytes = 0 → nothing is read → hash of empty input
1805        let result = sha256_first_n_bytes(path.to_str().unwrap(), 0).unwrap();
1806        let expected = format!("{:x}", Sha256::digest(b""));
1807        assert_eq!(result, expected);
1808
1809        let _ = std::fs::remove_file(path);
1810    }
1811
1812    // ── write_image (via temp files) ─────────────────────────────────────────
1813
1814    #[test]
1815    fn test_write_image_to_temp_file() {
1816        let dir = std::env::temp_dir();
1817        let img_path = dir.join("fk_write_img.bin");
1818        let dev_path = dir.join("fk_write_dev.bin");
1819
1820        let image_size: u64 = 2 * 1024 * 1024; // 2 MiB
1821        {
1822            let mut f = std::fs::File::create(&img_path).unwrap();
1823            let block: Vec<u8> = (0u8..=255u8).cycle().take(BLOCK_SIZE).collect();
1824            let mut rem = image_size;
1825            while rem > 0 {
1826                let n = rem.min(BLOCK_SIZE as u64) as usize;
1827                f.write_all(&block[..n]).unwrap();
1828                rem -= n as u64;
1829            }
1830        }
1831        std::fs::File::create(&dev_path).unwrap();
1832
1833        let (tx, rx) = make_channel();
1834        let cancel = Arc::new(AtomicBool::new(false));
1835
1836        let result = write_image(
1837            img_path.to_str().unwrap(),
1838            dev_path.to_str().unwrap(),
1839            image_size,
1840            &tx,
1841            &cancel,
1842        );
1843
1844        assert!(result.is_ok(), "write_image failed: {result:?}");
1845
1846        let written = std::fs::read(&dev_path).unwrap();
1847        let original = std::fs::read(&img_path).unwrap();
1848        assert_eq!(written, original, "written data must match image exactly");
1849
1850        let events = drain(&rx);
1851        let has_progress = events
1852            .iter()
1853            .any(|e| matches!(e, FlashEvent::Progress { .. }));
1854        assert!(has_progress, "must emit at least one Progress event");
1855
1856        let _ = std::fs::remove_file(img_path);
1857        let _ = std::fs::remove_file(dev_path);
1858    }
1859
1860    #[test]
1861    fn test_write_image_cancelled_mid_write() {
1862        let dir = std::env::temp_dir();
1863        let img_path = dir.join("fk_cancel_img.bin");
1864        let dev_path = dir.join("fk_cancel_dev.bin");
1865
1866        // Large enough that we definitely hit the cancel check.
1867        let image_size: u64 = 8 * 1024 * 1024; // 8 MiB
1868        {
1869            let mut f = std::fs::File::create(&img_path).unwrap();
1870            let block = vec![0xAAu8; BLOCK_SIZE];
1871            let mut rem = image_size;
1872            while rem > 0 {
1873                let n = rem.min(BLOCK_SIZE as u64) as usize;
1874                f.write_all(&block[..n]).unwrap();
1875                rem -= n as u64;
1876            }
1877        }
1878        std::fs::File::create(&dev_path).unwrap();
1879
1880        let (tx, _rx) = make_channel();
1881        let cancel = Arc::new(AtomicBool::new(true)); // pre-cancelled
1882
1883        let result = write_image(
1884            img_path.to_str().unwrap(),
1885            dev_path.to_str().unwrap(),
1886            image_size,
1887            &tx,
1888            &cancel,
1889        );
1890
1891        assert!(result.is_err());
1892        assert!(
1893            result.unwrap_err().contains("cancelled"),
1894            "error should mention cancellation"
1895        );
1896
1897        let _ = std::fs::remove_file(img_path);
1898        let _ = std::fs::remove_file(dev_path);
1899    }
1900
1901    #[test]
1902    fn test_write_image_missing_image_returns_error() {
1903        let dir = std::env::temp_dir();
1904        let dev_path = dir.join("fk_noimg_dev.bin");
1905        std::fs::File::create(&dev_path).unwrap();
1906
1907        let (tx, _rx) = make_channel();
1908        let cancel = Arc::new(AtomicBool::new(false));
1909
1910        let result = write_image(
1911            "/nonexistent/image.img",
1912            dev_path.to_str().unwrap(),
1913            1024,
1914            &tx,
1915            &cancel,
1916        );
1917
1918        assert!(result.is_err());
1919        assert!(result.unwrap_err().contains("Cannot open image"));
1920
1921        let _ = std::fs::remove_file(dev_path);
1922    }
1923
1924    // ── verify ───────────────────────────────────────────────────────────────
1925
1926    #[test]
1927    fn test_verify_matching_files() {
1928        let dir = std::env::temp_dir();
1929        let img = dir.join("fk_verify_img.bin");
1930        let dev = dir.join("fk_verify_dev.bin");
1931        let data = vec![0xBBu8; 64 * 1024];
1932        std::fs::write(&img, &data).unwrap();
1933        std::fs::write(&dev, &data).unwrap();
1934
1935        let (tx, _rx) = make_channel();
1936        let result = verify(
1937            img.to_str().unwrap(),
1938            dev.to_str().unwrap(),
1939            data.len() as u64,
1940            &tx,
1941        );
1942        assert!(result.is_ok());
1943
1944        let _ = std::fs::remove_file(img);
1945        let _ = std::fs::remove_file(dev);
1946    }
1947
1948    #[test]
1949    fn test_verify_mismatch_returns_error() {
1950        let dir = std::env::temp_dir();
1951        let img = dir.join("fk_mismatch_img.bin");
1952        let dev = dir.join("fk_mismatch_dev.bin");
1953        std::fs::write(&img, vec![0x00u8; 64 * 1024]).unwrap();
1954        std::fs::write(&dev, vec![0xFFu8; 64 * 1024]).unwrap();
1955
1956        let (tx, _rx) = make_channel();
1957        let result = verify(img.to_str().unwrap(), dev.to_str().unwrap(), 64 * 1024, &tx);
1958        assert!(result.is_err());
1959        assert!(result.unwrap_err().contains("Verification failed"));
1960
1961        let _ = std::fs::remove_file(img);
1962        let _ = std::fs::remove_file(dev);
1963    }
1964
1965    #[test]
1966    fn test_verify_only_checks_image_size_bytes() {
1967        let dir = std::env::temp_dir();
1968        let img = dir.join("fk_trunc_img.bin");
1969        let dev = dir.join("fk_trunc_dev.bin");
1970        let image_data = vec![0xCCu8; 32 * 1024];
1971        let mut device_data = image_data.clone();
1972        device_data.extend_from_slice(&[0xDDu8; 32 * 1024]);
1973        std::fs::write(&img, &image_data).unwrap();
1974        std::fs::write(&dev, &device_data).unwrap();
1975
1976        let (tx, _rx) = make_channel();
1977        let result = verify(
1978            img.to_str().unwrap(),
1979            dev.to_str().unwrap(),
1980            image_data.len() as u64,
1981            &tx,
1982        );
1983        assert!(
1984            result.is_ok(),
1985            "should pass when first N bytes match: {result:?}"
1986        );
1987
1988        let _ = std::fs::remove_file(img);
1989        let _ = std::fs::remove_file(dev);
1990    }
1991
1992    // ── flash_pipeline validation ────────────────────────────────────────────
1993
1994    #[test]
1995    fn test_pipeline_rejects_missing_image() {
1996        let (tx, rx) = make_channel();
1997        let cancel = Arc::new(AtomicBool::new(false));
1998        run_pipeline("/nonexistent/image.iso", "/dev/null", tx, cancel);
1999        let events = drain(&rx);
2000        let err = find_error(&events);
2001        assert!(err.is_some(), "must emit an Error event");
2002        assert!(err.unwrap().contains("Image file not found"), "err={err:?}");
2003    }
2004
2005    #[test]
2006    fn test_pipeline_rejects_empty_image() {
2007        let dir = std::env::temp_dir();
2008        let empty = dir.join("fk_empty.img");
2009        std::fs::write(&empty, b"").unwrap();
2010
2011        let (tx, rx) = make_channel();
2012        let cancel = Arc::new(AtomicBool::new(false));
2013        run_pipeline(empty.to_str().unwrap(), "/dev/null", tx, cancel);
2014
2015        let events = drain(&rx);
2016        let err = find_error(&events);
2017        assert!(err.is_some());
2018        assert!(err.unwrap().contains("empty"), "err={err:?}");
2019
2020        let _ = std::fs::remove_file(empty);
2021    }
2022
2023    #[test]
2024    fn test_pipeline_rejects_missing_device() {
2025        let dir = std::env::temp_dir();
2026        let img = dir.join("fk_nodev_img.bin");
2027        std::fs::write(&img, vec![0u8; 1024]).unwrap();
2028
2029        let (tx, rx) = make_channel();
2030        let cancel = Arc::new(AtomicBool::new(false));
2031        run_pipeline(img.to_str().unwrap(), "/nonexistent/device", tx, cancel);
2032
2033        let events = drain(&rx);
2034        let err = find_error(&events);
2035        assert!(err.is_some());
2036        assert!(
2037            err.unwrap().contains("Target device not found"),
2038            "err={err:?}"
2039        );
2040
2041        let _ = std::fs::remove_file(img);
2042    }
2043
2044    /// End-to-end pipeline test using only temp files (no real hardware).
2045    #[test]
2046    fn test_pipeline_end_to_end_temp_files() {
2047        let dir = std::env::temp_dir();
2048        let img = dir.join("fk_e2e_img.bin");
2049        let dev = dir.join("fk_e2e_dev.bin");
2050
2051        let image_data: Vec<u8> = (0u8..=255u8).cycle().take(1024 * 1024).collect();
2052        std::fs::write(&img, &image_data).unwrap();
2053        std::fs::File::create(&dev).unwrap();
2054
2055        let (tx, rx) = make_channel();
2056        let cancel = Arc::new(AtomicBool::new(false));
2057        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
2058
2059        let events = drain(&rx);
2060
2061        // Must have seen at least one Progress event.
2062        let has_progress = events
2063            .iter()
2064            .any(|e| matches!(e, FlashEvent::Progress { .. }));
2065        assert!(has_progress, "must emit Progress events");
2066
2067        // Must have passed through the core pipeline stages.
2068        assert!(
2069            has_stage(&events, &FlashStage::Unmounting),
2070            "must emit Unmounting stage"
2071        );
2072        assert!(
2073            has_stage(&events, &FlashStage::Writing),
2074            "must emit Writing stage"
2075        );
2076        assert!(
2077            has_stage(&events, &FlashStage::Syncing),
2078            "must emit Syncing stage"
2079        );
2080
2081        // On temp files the pipeline either completes (Done) or fails after
2082        // the write/verify stage (e.g. BLKRRPART on a regular file).
2083        let has_done = events.iter().any(|e| matches!(e, FlashEvent::Done));
2084        let has_error = events.iter().any(|e| matches!(e, FlashEvent::Error(_)));
2085        assert!(
2086            has_done || has_error,
2087            "pipeline must end with Done or Error"
2088        );
2089
2090        if has_done {
2091            let written = std::fs::read(&dev).unwrap();
2092            assert_eq!(written, image_data, "written data must match image");
2093        } else if let Some(err_msg) = find_error(&events) {
2094            // Error must NOT be from write or verify.
2095            assert!(
2096                !err_msg.contains("Cannot open")
2097                    && !err_msg.contains("Verification failed")
2098                    && !err_msg.contains("Write error"),
2099                "unexpected error: {err_msg}"
2100            );
2101        }
2102
2103        let _ = std::fs::remove_file(img);
2104        let _ = std::fs::remove_file(dev);
2105    }
2106
2107    // ── FlashStage Display ───────────────────────────────────────────────────
2108
2109    #[test]
2110    fn test_flash_stage_display() {
2111        assert!(FlashStage::Writing.to_string().contains("Writing"));
2112        assert!(FlashStage::Syncing.to_string().contains("Flushing"));
2113        assert!(FlashStage::Done.to_string().contains("complete"));
2114        assert!(FlashStage::Failed("oops".into())
2115            .to_string()
2116            .contains("oops"));
2117    }
2118
2119    // ── FlashStage equality ──────────────────────────────────────────────────
2120
2121    #[test]
2122    fn test_flash_stage_eq() {
2123        assert_eq!(FlashStage::Writing, FlashStage::Writing);
2124        assert_ne!(FlashStage::Writing, FlashStage::Syncing);
2125        assert_eq!(
2126            FlashStage::Failed("x".into()),
2127            FlashStage::Failed("x".into())
2128        );
2129        assert_ne!(
2130            FlashStage::Failed("x".into()),
2131            FlashStage::Failed("y".into())
2132        );
2133    }
2134
2135    // ── FlashEvent Clone ─────────────────────────────────────────────────────
2136
2137    #[test]
2138    fn test_flash_event_clone() {
2139        let events = vec![
2140            FlashEvent::Stage(FlashStage::Writing),
2141            FlashEvent::Progress {
2142                bytes_written: 1024,
2143                total_bytes: 4096,
2144                speed_mb_s: 12.5,
2145            },
2146            FlashEvent::Log("hello".into()),
2147            FlashEvent::Done,
2148            FlashEvent::Error("boom".into()),
2149        ];
2150        for e in &events {
2151            let _ = e.clone(); // must not panic
2152        }
2153    }
2154
2155    // ── find_mounted_partitions (platform-neutral contracts) ─────────────────
2156
2157    /// Calling find_mounted_partitions with a device name that almost
2158    /// certainly isn't mounted must return an empty Vec without panicking.
2159    #[test]
2160    fn test_find_mounted_partitions_nonexistent_device_returns_empty() {
2161        // PhysicalDrive999 / sdzzz are both guaranteed not to exist anywhere.
2162        #[cfg(target_os = "windows")]
2163        let result = find_mounted_partitions("PhysicalDrive999", r"\\.\PhysicalDrive999");
2164        #[cfg(not(target_os = "windows"))]
2165        let result = find_mounted_partitions("sdzzz", "/dev/sdzzz");
2166
2167        // Result can be empty or non-empty depending on the OS, but must not panic.
2168        let _ = result;
2169    }
2170
2171    /// find_mounted_partitions must return a Vec (never panic) even when
2172    /// called with an empty device name.
2173    #[test]
2174    fn test_find_mounted_partitions_empty_name_no_panic() {
2175        let result = find_mounted_partitions("", "");
2176        let _ = result;
2177    }
2178
2179    // ── is_partition_of (Windows drive-letter paths are not partitions) ──────
2180
2181    /// On Windows the caller never passes Unix-style paths, so these should
2182    /// all return false (no false positives from the partition-suffix logic).
2183    #[test]
2184    fn test_is_partition_of_windows_style_paths() {
2185        // Windows physical drive paths have no numeric suffix after the name.
2186        assert!(!is_partition_of(r"\\.\PhysicalDrive0", "PhysicalDrive0"));
2187        assert!(!is_partition_of(r"\\.\PhysicalDrive1", "PhysicalDrive0"));
2188    }
2189
2190    // ── sync_device (via pipeline — emits Log event on all platforms) ────────
2191
2192    /// sync_device must emit a "caches flushed" log event regardless of
2193    /// platform.  We test this indirectly via the full pipeline on temp files.
2194    #[test]
2195    fn test_pipeline_emits_syncing_stage() {
2196        let dir = std::env::temp_dir();
2197        let img = dir.join("fk_sync_stage_img.bin");
2198        let dev = dir.join("fk_sync_stage_dev.bin");
2199
2200        let data: Vec<u8> = (0u8..=255).cycle().take(512 * 1024).collect();
2201        std::fs::write(&img, &data).unwrap();
2202        std::fs::File::create(&dev).unwrap();
2203
2204        let (tx, rx) = make_channel();
2205        let cancel = Arc::new(AtomicBool::new(false));
2206        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
2207
2208        let events = drain(&rx);
2209        assert!(
2210            has_stage(&events, &FlashStage::Syncing),
2211            "Syncing stage must be emitted on every platform"
2212        );
2213
2214        let _ = std::fs::remove_file(&img);
2215        let _ = std::fs::remove_file(&dev);
2216    }
2217
2218    /// The pipeline must emit the Rereading stage on every platform.
2219    #[test]
2220    fn test_pipeline_emits_rereading_stage() {
2221        let dir = std::env::temp_dir();
2222        let img = dir.join("fk_reread_stage_img.bin");
2223        let dev = dir.join("fk_reread_stage_dev.bin");
2224
2225        let data: Vec<u8> = vec![0xABu8; 256 * 1024];
2226        std::fs::write(&img, &data).unwrap();
2227        std::fs::File::create(&dev).unwrap();
2228
2229        let (tx, rx) = make_channel();
2230        let cancel = Arc::new(AtomicBool::new(false));
2231        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
2232
2233        let events = drain(&rx);
2234        assert!(
2235            has_stage(&events, &FlashStage::Rereading),
2236            "Rereading stage must be emitted on every platform"
2237        );
2238
2239        let _ = std::fs::remove_file(&img);
2240        let _ = std::fs::remove_file(&dev);
2241    }
2242
2243    /// The pipeline must emit the Verifying stage on every platform.
2244    #[test]
2245    fn test_pipeline_emits_verifying_stage() {
2246        let dir = std::env::temp_dir();
2247        let img = dir.join("fk_verify_stage_img.bin");
2248        let dev = dir.join("fk_verify_stage_dev.bin");
2249
2250        let data: Vec<u8> = vec![0xCDu8; 256 * 1024];
2251        std::fs::write(&img, &data).unwrap();
2252        std::fs::File::create(&dev).unwrap();
2253
2254        let (tx, rx) = make_channel();
2255        let cancel = Arc::new(AtomicBool::new(false));
2256        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
2257
2258        let events = drain(&rx);
2259        assert!(
2260            has_stage(&events, &FlashStage::Verifying),
2261            "Verifying stage must be emitted on every platform"
2262        );
2263
2264        let _ = std::fs::remove_file(&img);
2265        let _ = std::fs::remove_file(&dev);
2266    }
2267
2268    // ── open_device_for_writing error messages ───────────────────────────────
2269
2270    /// Opening a path that does not exist must produce an error that mentions
2271    /// the device path — verified on all platforms.
2272    #[test]
2273    fn test_open_device_for_writing_nonexistent_mentions_path() {
2274        let bad = if cfg!(target_os = "windows") {
2275            r"\\.\PhysicalDrive999".to_string()
2276        } else {
2277            "/nonexistent/fk_bad_device".to_string()
2278        };
2279
2280        // open_device_for_writing is private; exercise it via write_image.
2281        let dir = std::env::temp_dir();
2282        let img = dir.join("fk_open_err_img.bin");
2283        std::fs::write(&img, vec![1u8; 512]).unwrap();
2284
2285        let (tx, _rx) = make_channel();
2286        let cancel = Arc::new(AtomicBool::new(false));
2287        let result = write_image(img.to_str().unwrap(), &bad, 512, &tx, &cancel);
2288
2289        assert!(result.is_err(), "must fail for nonexistent device");
2290        // The error string should mention the device path.
2291        assert!(
2292            result.as_ref().unwrap_err().contains("PhysicalDrive999")
2293                || result.as_ref().unwrap_err().contains("fk_bad_device")
2294                || result.as_ref().unwrap_err().contains("Cannot open"),
2295            "error should reference the bad path: {:?}",
2296            result
2297        );
2298
2299        let _ = std::fs::remove_file(&img);
2300    }
2301
2302    // ── sync_device emits a log message ─────────────────────────────────────
2303
2304    /// sync_device must emit at least one FlashEvent::Log containing the
2305    /// word "flushed" or "flush" on every platform.
2306    #[test]
2307    fn test_sync_device_emits_log() {
2308        let dir = std::env::temp_dir();
2309        let dev = dir.join("fk_sync_log_dev.bin");
2310        std::fs::File::create(&dev).unwrap();
2311
2312        let (tx, rx) = make_channel();
2313        sync_device(dev.to_str().unwrap(), &tx);
2314
2315        let events = drain(&rx);
2316        let has_flush_log = events.iter().any(|e| {
2317            if let FlashEvent::Log(msg) = e {
2318                let lower = msg.to_lowercase();
2319                lower.contains("flush") || lower.contains("cache")
2320            } else {
2321                false
2322            }
2323        });
2324        assert!(
2325            has_flush_log,
2326            "sync_device must emit a flush/cache log event"
2327        );
2328
2329        let _ = std::fs::remove_file(&dev);
2330    }
2331
2332    // ── reread_partition_table emits a log message ───────────────────────────
2333
2334    /// reread_partition_table must emit at least one FlashEvent::Log on every
2335    /// platform — either a success message or a warning.
2336    #[test]
2337    fn test_reread_partition_table_emits_log() {
2338        let dir = std::env::temp_dir();
2339        let dev = dir.join("fk_reread_log_dev.bin");
2340        std::fs::File::create(&dev).unwrap();
2341
2342        let (tx, rx) = make_channel();
2343        reread_partition_table(dev.to_str().unwrap(), &tx);
2344
2345        let events = drain(&rx);
2346        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2347        assert!(
2348            has_log,
2349            "reread_partition_table must emit at least one Log event"
2350        );
2351
2352        let _ = std::fs::remove_file(&dev);
2353    }
2354
2355    // ── unmount_device emits a log message ───────────────────────────────────
2356
2357    /// unmount_device on a temp-file path (which is never mounted) must emit
2358    /// the "no mounted partitions" log without panicking on any platform.
2359    #[test]
2360    fn test_unmount_device_no_partitions_emits_log() {
2361        let dir = std::env::temp_dir();
2362        let dev = dir.join("fk_unmount_log_dev.bin");
2363        std::fs::File::create(&dev).unwrap();
2364
2365        let path_str = dev.to_str().unwrap();
2366        let (tx, rx) = make_channel();
2367        unmount_device(path_str, &tx);
2368
2369        let events = drain(&rx);
2370        // Must emit at least one Log event (either "no partitions" or a warning).
2371        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2372        assert!(has_log, "unmount_device must emit at least one Log event");
2373
2374        let _ = std::fs::remove_file(&dev);
2375    }
2376
2377    // ── Pipeline all-stages ordering ─────────────────────────────────────────
2378
2379    /// The pipeline must emit stages in the documented order:
2380    /// Unmounting → Writing → Syncing → Rereading → Verifying.
2381    #[test]
2382    fn test_pipeline_stage_ordering() {
2383        let dir = std::env::temp_dir();
2384        let img = dir.join("fk_order_img.bin");
2385        let dev = dir.join("fk_order_dev.bin");
2386
2387        let data: Vec<u8> = (0u8..=255).cycle().take(256 * 1024).collect();
2388        std::fs::write(&img, &data).unwrap();
2389        std::fs::File::create(&dev).unwrap();
2390
2391        let (tx, rx) = make_channel();
2392        let cancel = Arc::new(AtomicBool::new(false));
2393        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
2394
2395        let events = drain(&rx);
2396
2397        // Collect all Stage events in order.
2398        let stages: Vec<&FlashStage> = events
2399            .iter()
2400            .filter_map(|e| {
2401                if let FlashEvent::Stage(s) = e {
2402                    Some(s)
2403                } else {
2404                    None
2405                }
2406            })
2407            .collect();
2408
2409        // Verify the mandatory stages appear and in correct relative order.
2410        let pos = |target: &FlashStage| {
2411            stages
2412                .iter()
2413                .position(|s| *s == target)
2414                .unwrap_or(usize::MAX)
2415        };
2416
2417        let unmounting = pos(&FlashStage::Unmounting);
2418        let writing = pos(&FlashStage::Writing);
2419        let syncing = pos(&FlashStage::Syncing);
2420        let rereading = pos(&FlashStage::Rereading);
2421        let verifying = pos(&FlashStage::Verifying);
2422
2423        assert!(unmounting < writing, "Unmounting must precede Writing");
2424        assert!(writing < syncing, "Writing must precede Syncing");
2425        assert!(syncing < rereading, "Syncing must precede Rereading");
2426        assert!(rereading < verifying, "Rereading must precede Verifying");
2427
2428        let _ = std::fs::remove_file(&img);
2429        let _ = std::fs::remove_file(&dev);
2430    }
2431
2432    // ── Linux-specific tests ─────────────────────────────────────────────────
2433
2434    /// On Linux, find_mounted_partitions reads /proc/mounts.
2435    /// Verify it returns a Vec without panicking (live test).
2436    #[test]
2437    #[cfg(target_os = "linux")]
2438    fn test_find_mounted_partitions_linux_no_panic() {
2439        // sda is unlikely to be mounted in CI, but the function must not panic.
2440        let result = find_mounted_partitions("sda", "/dev/sda");
2441        let _ = result;
2442    }
2443
2444    /// On Linux, /proc/mounts always contains at least one line (the root
2445    /// filesystem), so reading a clearly-mounted device (e.g. something at /)
2446    /// should find entries.
2447    #[test]
2448    #[cfg(target_os = "linux")]
2449    fn test_find_mounted_partitions_linux_reads_proc_mounts() {
2450        // We can't know exactly which device is at /, but we can verify
2451        // that the function can parse whatever /proc/mounts contains.
2452        let content = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
2453        // If /proc/mounts is non-empty there must be at least one entry parseable.
2454        if !content.is_empty() {
2455            // Parse first real /dev/ device from /proc/mounts and verify
2456            // find_mounted_partitions does not panic on it.
2457            if let Some(line) = content.lines().find(|l| l.starts_with("/dev/")) {
2458                if let Some(dev) = line.split_whitespace().next() {
2459                    let name = std::path::Path::new(dev)
2460                        .file_name()
2461                        .map(|n| n.to_string_lossy().to_string())
2462                        .unwrap_or_default();
2463                    let _ = find_mounted_partitions(&name, dev);
2464                }
2465            }
2466        }
2467    }
2468
2469    /// On Linux, do_unmount on a path that is not mounted must not panic.
2470    /// EINVAL (not a mount point) and ENOENT (path doesn't exist) are both
2471    /// silenced — they are normal/harmless conditions, not warnings to surface
2472    /// to the user.
2473    #[test]
2474    #[cfg(target_os = "linux")]
2475    fn test_do_unmount_not_mounted_does_not_panic() {
2476        let (tx, rx) = make_channel();
2477        do_unmount("/dev/fk_nonexistent_part", &tx);
2478        let events = drain(&rx);
2479        // EINVAL / ENOENT must NOT produce a warning log — they are expected
2480        // silent outcomes when a partition is already detached or never mounted.
2481        let has_warning = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2482        assert!(
2483            !has_warning,
2484            "do_unmount must not emit a warning for EINVAL/ENOENT: {events:?}"
2485        );
2486    }
2487
2488    // ── macOS-specific tests ─────────────────────────────────────────────────
2489
2490    /// On macOS, do_unmount with a bogus partition path must emit a warning
2491    /// log (diskutil will fail) but must not panic.
2492    #[test]
2493    #[cfg(target_os = "macos")]
2494    fn test_do_unmount_macos_bad_path_emits_warning() {
2495        let (tx, rx) = make_channel();
2496        do_unmount("/dev/fk_nonexistent_part", &tx);
2497        let events = drain(&rx);
2498        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2499        assert!(has_log, "do_unmount must emit a Log event on failure");
2500    }
2501
2502    /// On macOS, find_mounted_partitions reads /proc/mounts (which doesn't
2503    /// exist) or falls back gracefully — must not panic.
2504    #[test]
2505    #[cfg(target_os = "macos")]
2506    fn test_find_mounted_partitions_macos_no_panic() {
2507        let result = find_mounted_partitions("disk2", "/dev/disk2");
2508        let _ = result;
2509    }
2510
2511    /// On macOS, reread_partition_table calls diskutil — must emit a log even
2512    /// if the path is a temp file (diskutil will fail gracefully).
2513    #[test]
2514    #[cfg(target_os = "macos")]
2515    fn test_reread_partition_table_macos_emits_log() {
2516        let dir = std::env::temp_dir();
2517        let dev = dir.join("fk_macos_reread_dev.bin");
2518        std::fs::File::create(&dev).unwrap();
2519
2520        let (tx, rx) = make_channel();
2521        reread_partition_table(dev.to_str().unwrap(), &tx);
2522
2523        let events = drain(&rx);
2524        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2525        assert!(has_log, "reread_partition_table must emit a log on macOS");
2526
2527        let _ = std::fs::remove_file(&dev);
2528    }
2529
2530    // ── Windows-specific pipeline tests ─────────────────────────────────────
2531
2532    /// On Windows, find_mounted_partitions delegates to
2533    /// windows::find_volumes_on_physical_drive — verify it does not panic
2534    /// for a well-formed but nonexistent drive.
2535    #[test]
2536    #[cfg(target_os = "windows")]
2537    fn test_find_mounted_partitions_windows_nonexistent() {
2538        let result = find_mounted_partitions("PhysicalDrive999", r"\\.\PhysicalDrive999");
2539        assert!(
2540            result.is_empty(),
2541            "nonexistent physical drive should have no volumes"
2542        );
2543    }
2544
2545    /// On Windows, do_unmount on a bad volume path must emit a warning log
2546    /// and not panic.
2547    #[test]
2548    #[cfg(target_os = "windows")]
2549    fn test_do_unmount_windows_bad_volume_emits_log() {
2550        let (tx, rx) = make_channel();
2551        do_unmount(r"\\.\Z99:", &tx);
2552        let events = drain(&rx);
2553        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2554        assert!(has_log, "do_unmount on bad volume must emit a Log event");
2555    }
2556
2557    /// On Windows, sync_device on a nonexistent physical drive path should
2558    /// emit a warning log (FlushFileBuffers will fail) but not panic.
2559    #[test]
2560    #[cfg(target_os = "windows")]
2561    fn test_sync_device_windows_bad_path_no_panic() {
2562        let (tx, rx) = make_channel();
2563        sync_device(r"\\.\PhysicalDrive999", &tx);
2564        let events = drain(&rx);
2565        // Must emit at least one log event (either flush warning or the
2566        // normal "caches flushed" message).
2567        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2568        assert!(has_log, "sync_device must emit a Log event on Windows");
2569    }
2570
2571    /// On Windows, reread_partition_table on a nonexistent drive must emit
2572    /// a warning log and not panic.
2573    #[test]
2574    #[cfg(target_os = "windows")]
2575    fn test_reread_partition_table_windows_bad_path_no_panic() {
2576        let (tx, rx) = make_channel();
2577        reread_partition_table(r"\\.\PhysicalDrive999", &tx);
2578        let events = drain(&rx);
2579        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2580        assert!(
2581            has_log,
2582            "reread_partition_table must emit a Log event on Windows"
2583        );
2584    }
2585
2586    /// On Windows, open_device_for_writing on a nonexistent physical drive
2587    /// must return an Err containing a meaningful message.
2588    #[test]
2589    #[cfg(target_os = "windows")]
2590    fn test_open_device_for_writing_windows_access_denied_message() {
2591        let dir = std::env::temp_dir();
2592        let img = dir.join("fk_win_open_img.bin");
2593        std::fs::write(&img, vec![1u8; 512]).unwrap();
2594
2595        let (tx, _rx) = make_channel();
2596        let cancel = Arc::new(AtomicBool::new(false));
2597        let result = write_image(
2598            img.to_str().unwrap(),
2599            r"\\.\PhysicalDrive999",
2600            512,
2601            &tx,
2602            &cancel,
2603        );
2604
2605        assert!(result.is_err());
2606        let msg = result.unwrap_err();
2607        // Must mention either the path, or give a clear error.
2608        assert!(
2609            msg.contains("PhysicalDrive999")
2610                || msg.contains("Access denied")
2611                || msg.contains("Cannot open"),
2612            "error must be descriptive: {msg}"
2613        );
2614
2615        let _ = std::fs::remove_file(&img);
2616    }
2617    // ── FlashStage::progress_floor ────────────────────────────────────────────
2618
2619    #[test]
2620    fn flash_stage_progress_floor_syncing() {
2621        assert!((FlashStage::Syncing.progress_floor() - 0.80).abs() < f32::EPSILON);
2622    }
2623
2624    #[test]
2625    fn flash_stage_progress_floor_rereading() {
2626        assert!((FlashStage::Rereading.progress_floor() - 0.88).abs() < f32::EPSILON);
2627    }
2628
2629    #[test]
2630    fn flash_stage_progress_floor_verifying() {
2631        assert!((FlashStage::Verifying.progress_floor() - 0.92).abs() < f32::EPSILON);
2632    }
2633
2634    #[test]
2635    fn flash_stage_progress_floor_other_stages_are_zero() {
2636        for stage in [
2637            FlashStage::Starting,
2638            FlashStage::Unmounting,
2639            FlashStage::Writing,
2640            FlashStage::Done,
2641        ] {
2642            assert_eq!(
2643                stage.progress_floor(),
2644                0.0,
2645                "{stage:?} should have floor 0.0"
2646            );
2647        }
2648    }
2649
2650    // ── verify_overall_progress ───────────────────────────────────────────────
2651
2652    #[test]
2653    fn verify_overall_image_phase_start() {
2654        assert_eq!(verify_overall_progress("image", 0.0), 0.0);
2655    }
2656
2657    #[test]
2658    fn verify_overall_image_phase_end() {
2659        assert!((verify_overall_progress("image", 1.0) - 0.5).abs() < f32::EPSILON);
2660    }
2661
2662    #[test]
2663    fn verify_overall_image_phase_midpoint() {
2664        assert!((verify_overall_progress("image", 0.5) - 0.25).abs() < f32::EPSILON);
2665    }
2666
2667    #[test]
2668    fn verify_overall_device_phase_start() {
2669        assert!((verify_overall_progress("device", 0.0) - 0.5).abs() < f32::EPSILON);
2670    }
2671
2672    #[test]
2673    fn verify_overall_device_phase_end() {
2674        assert!((verify_overall_progress("device", 1.0) - 1.0).abs() < f32::EPSILON);
2675    }
2676
2677    #[test]
2678    fn verify_overall_device_phase_midpoint() {
2679        assert!((verify_overall_progress("device", 0.5) - 0.75).abs() < f32::EPSILON);
2680    }
2681
2682    #[test]
2683    fn verify_overall_unknown_phase_treated_as_device() {
2684        // Any phase that is not "image" falls into the device branch.
2685        assert!((verify_overall_progress("other", 0.0) - 0.5).abs() < f32::EPSILON);
2686    }
2687}