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