Skip to main content

flashkraft_core/
flash_helper.rs

1//! Flash Pipeline
2//!
3//! Implements the entire privileged flash pipeline in-process.
4//!
5//! ## Privilege model
6//!
7//! The installed binary carries the **setuid-root** bit
8//! (`sudo chmod u+s /usr/bin/flashkraft`).  At process startup `main.rs`
9//! calls [`set_real_uid`] to record the unprivileged user's UID.
10//!
11//! When the pipeline needs to open a raw block device it temporarily
12//! escalates to root via `nix::unistd::seteuid(0)`, opens the file
13//! descriptor, then immediately drops back to the real UID.  Root is held
14//! for less than one millisecond.
15//!
16//! ## Progress reporting
17//!
18//! The pipeline runs on a dedicated blocking thread spawned by the flash
19//! subscription.  Progress is reported by sending [`FlashEvent`] values
20//! through a [`std::sync::mpsc::Sender`] — no child process, no stdout
21//! parsing, no IPC protocol.
22//!
23//! ## Pipeline stages
24//!
25//! 1. Validate inputs (image exists and is non-empty, device exists, not a partition node)
26//! 2. Unmount all partitions of the target device (lazy / `MNT_DETACH`)
27//! 3. Write the image in 4 MiB blocks, reporting progress every 400 ms
28//! 4. `fsync` the device fd (hard error on failure)
29//! 5. `fdatasync` + global `sync()` (belt-and-suspenders)
30//! 6. `BLKRRPART` ioctl — ask the kernel to re-read the partition table
31//! 7. SHA-256 verify: hash the source image, hash the first N bytes of the device, compare
32
33#[cfg(unix)]
34use nix::libc;
35use std::io::{self, Read, Write};
36use std::path::Path;
37use std::sync::{
38    atomic::{AtomicBool, Ordering},
39    mpsc, Arc, OnceLock,
40};
41use std::time::{Duration, Instant};
42
43// ---------------------------------------------------------------------------
44// Constants
45// ---------------------------------------------------------------------------
46
47/// Write / read-back buffer: 4 MiB is a sweet spot for USB throughput.
48const BLOCK_SIZE: usize = 4 * 1024 * 1024;
49
50/// Minimum interval between `FlashEvent::Progress` emissions.
51const PROGRESS_INTERVAL: Duration = Duration::from_millis(400);
52
53// ---------------------------------------------------------------------------
54// Real-UID registry
55// ---------------------------------------------------------------------------
56
57/// The unprivileged UID of the user who launched the process.
58///
59/// Captured in `main.rs` via `nix::unistd::getuid()` before any `seteuid`
60/// call and stored here via [`set_real_uid`].
61static REAL_UID: OnceLock<u32> = OnceLock::new();
62
63/// Store the real (unprivileged) UID of the process owner.
64///
65/// Must be called once from `main()` before any flash operation.
66/// On non-Unix platforms this is a no-op.
67pub fn set_real_uid(uid: u32) {
68    let _ = REAL_UID.set(uid);
69}
70
71/// Retrieve the stored real UID, falling back to the current effective UID.
72#[cfg(unix)]
73fn real_uid() -> nix::unistd::Uid {
74    let raw = REAL_UID
75        .get()
76        .copied()
77        .unwrap_or_else(|| nix::unistd::getuid().as_raw());
78    nix::unistd::Uid::from_raw(raw)
79}
80
81// ---------------------------------------------------------------------------
82// Public types
83// ---------------------------------------------------------------------------
84
85/// A stage in the five-step flash pipeline.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum FlashStage {
88    /// Initial state before the pipeline starts.
89    Starting,
90    /// All partitions of the target device are being lazily unmounted.
91    Unmounting,
92    /// The image is being written to the block device in 4 MiB chunks.
93    Writing,
94    /// Kernel write-back caches are being flushed (`fsync` / `sync`).
95    Syncing,
96    /// The kernel is asked to re-read the partition table (`BLKRRPART`).
97    Rereading,
98    /// SHA-256 of the source image is compared against a read-back of the device.
99    Verifying,
100    /// The entire pipeline completed successfully.
101    Done,
102    /// The pipeline terminated with an error.
103    Failed(String),
104}
105
106impl std::fmt::Display for FlashStage {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            FlashStage::Starting => write!(f, "Starting…"),
110            FlashStage::Unmounting => write!(f, "Unmounting partitions…"),
111            FlashStage::Writing => write!(f, "Writing image to device…"),
112            FlashStage::Syncing => write!(f, "Flushing write buffers…"),
113            FlashStage::Rereading => write!(f, "Refreshing partition table…"),
114            FlashStage::Verifying => write!(f, "Verifying written data…"),
115            FlashStage::Done => write!(f, "Flash complete!"),
116            FlashStage::Failed(m) => write!(f, "Failed: {m}"),
117        }
118    }
119}
120
121/// A typed event emitted by the flash pipeline.
122///
123/// Sent over [`std::sync::mpsc`] to the async Iced subscription — no
124/// serialisation, no text parsing.
125#[derive(Debug, Clone)]
126pub enum FlashEvent {
127    /// A pipeline stage transition.
128    Stage(FlashStage),
129    /// Write-progress update.
130    Progress {
131        bytes_written: u64,
132        total_bytes: u64,
133        speed_mb_s: f32,
134    },
135    /// Informational log message (not an error).
136    Log(String),
137    /// The pipeline finished successfully.
138    Done,
139    /// The pipeline failed; the string is a human-readable error.
140    Error(String),
141}
142
143// ---------------------------------------------------------------------------
144// Public entry point
145// ---------------------------------------------------------------------------
146
147/// Run the full flash pipeline in the **calling thread**.
148///
149/// This function is blocking and must be called from a dedicated
150/// `std::thread::spawn` thread, not from an async executor.
151///
152/// # Arguments
153///
154/// * `image_path`  – path to the source image file
155/// * `device_path` – path to the target block device (e.g. `/dev/sdb`)
156/// * `tx`          – channel to send [`FlashEvent`] progress updates
157/// * `cancel`      – set to `true` to abort the pipeline between blocks
158pub fn run_pipeline(
159    image_path: &str,
160    device_path: &str,
161    tx: mpsc::Sender<FlashEvent>,
162    cancel: Arc<AtomicBool>,
163) {
164    if let Err(e) = flash_pipeline(image_path, device_path, &tx, cancel) {
165        let _ = tx.send(FlashEvent::Error(e));
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Top-level pipeline
171// ---------------------------------------------------------------------------
172
173fn send(tx: &mpsc::Sender<FlashEvent>, event: FlashEvent) {
174    // If the receiver is gone the GUI has been closed — ignore silently.
175    let _ = tx.send(event);
176}
177
178fn flash_pipeline(
179    image_path: &str,
180    device_path: &str,
181    tx: &mpsc::Sender<FlashEvent>,
182    cancel: Arc<AtomicBool>,
183) -> Result<(), String> {
184    // ── Validate inputs ──────────────────────────────────────────────────────
185    if !Path::new(image_path).is_file() {
186        return Err(format!("Image file not found: {image_path}"));
187    }
188
189    if !Path::new(device_path).exists() {
190        return Err(format!("Target device not found: {device_path}"));
191    }
192
193    // Guard against partition nodes (e.g. /dev/sdb1 instead of /dev/sdb).
194    #[cfg(target_os = "linux")]
195    reject_partition_node(device_path)?;
196
197    let image_size = std::fs::metadata(image_path)
198        .map_err(|e| format!("Cannot stat image: {e}"))?
199        .len();
200
201    if image_size == 0 {
202        return Err("Image file is empty".to_string());
203    }
204
205    // ── Step 1: Unmount ──────────────────────────────────────────────────────
206    send(tx, FlashEvent::Stage(FlashStage::Unmounting));
207    unmount_device(device_path, tx);
208
209    // ── Step 2: Write ────────────────────────────────────────────────────────
210    send(tx, FlashEvent::Stage(FlashStage::Writing));
211    send(
212        tx,
213        FlashEvent::Log(format!(
214            "Writing {image_size} bytes from {image_path} → {device_path}"
215        )),
216    );
217    write_image(image_path, device_path, image_size, tx, &cancel)?;
218
219    // ── Step 3: Sync ─────────────────────────────────────────────────────────
220    send(tx, FlashEvent::Stage(FlashStage::Syncing));
221    sync_device(device_path, tx);
222
223    // ── Step 4: Re-read partition table ──────────────────────────────────────
224    send(tx, FlashEvent::Stage(FlashStage::Rereading));
225    reread_partition_table(device_path, tx);
226
227    // ── Step 5: Verify ───────────────────────────────────────────────────────
228    send(tx, FlashEvent::Stage(FlashStage::Verifying));
229    verify(image_path, device_path, image_size, tx)?;
230
231    // ── Done ─────────────────────────────────────────────────────────────────
232    send(tx, FlashEvent::Done);
233    Ok(())
234}
235
236// ---------------------------------------------------------------------------
237// Partition-node guard (Linux only)
238// ---------------------------------------------------------------------------
239
240#[cfg(target_os = "linux")]
241fn reject_partition_node(device_path: &str) -> Result<(), String> {
242    let dev_name = Path::new(device_path)
243        .file_name()
244        .map(|n| n.to_string_lossy().to_string())
245        .unwrap_or_default();
246
247    let is_partition = {
248        let bytes = dev_name.as_bytes();
249        !bytes.is_empty() && bytes[bytes.len() - 1].is_ascii_digit() && {
250            let stem = dev_name.trim_end_matches(|c: char| c.is_ascii_digit());
251            stem.ends_with('p')
252                || (!stem.is_empty()
253                    && !stem.ends_with(|c: char| c.is_ascii_digit())
254                    && stem.chars().any(|c| c.is_ascii_alphabetic()))
255        }
256    };
257
258    if is_partition {
259        let whole = dev_name.trim_end_matches(|c: char| c.is_ascii_digit() || c == 'p');
260        return Err(format!(
261            "Refusing to write to partition node '{device_path}'. \
262             Select the whole-disk device (e.g. /dev/{whole}) instead."
263        ));
264    }
265
266    Ok(())
267}
268
269// ---------------------------------------------------------------------------
270// Privilege helpers
271// ---------------------------------------------------------------------------
272
273/// Open `device_path` for raw writing, temporarily escalating to root if the
274/// binary is setuid-root, then immediately dropping back to the real UID.
275fn open_device_for_writing(device_path: &str) -> Result<std::fs::File, String> {
276    #[cfg(unix)]
277    {
278        use nix::unistd::seteuid;
279
280        // Attempt to escalate to root.
281        //
282        // This only succeeds when the binary carries the setuid-root bit
283        // (`chmod u+s`).  If escalation fails we still try to open the file —
284        // it may be a regular writable file (e.g. during tests) or the user
285        // may already have write permission on the device.
286        let escalated = seteuid(nix::unistd::Uid::from_raw(0)).is_ok();
287
288        let result = std::fs::OpenOptions::new()
289            .write(true)
290            .open(device_path)
291            .map_err(|e| {
292                let raw = e.raw_os_error().unwrap_or(0);
293                if raw == libc::EACCES || raw == libc::EPERM {
294                    if escalated {
295                        format!(
296                            "Permission denied opening '{device_path}'.\n\
297                             Even with setuid-root the device refused access — \
298                             check that the device exists and is not in use."
299                        )
300                    } else {
301                        format!(
302                            "Permission denied opening '{device_path}'.\n\
303                             The binary is not installed setuid-root.\n\
304                             Install with:\n  \
305                             sudo chown root:root /usr/bin/flashkraft\n  \
306                             sudo chmod u+s      /usr/bin/flashkraft"
307                        )
308                    }
309                } else if raw == libc::EBUSY {
310                    format!(
311                        "Device '{device_path}' is busy. \
312                         Ensure all partitions are unmounted before flashing."
313                    )
314                } else {
315                    format!("Cannot open device '{device_path}' for writing: {e}")
316                }
317            });
318
319        // Drop back to the real (unprivileged) user immediately.
320        if escalated {
321            let _ = seteuid(real_uid());
322        }
323
324        result
325    }
326
327    #[cfg(not(unix))]
328    {
329        std::fs::OpenOptions::new()
330            .write(true)
331            .open(device_path)
332            .map_err(|e| {
333                let raw = e.raw_os_error().unwrap_or(0);
334                // ERROR_ACCESS_DENIED (5) or ERROR_PRIVILEGE_NOT_HELD (1314)
335                if raw == 5 || raw == 1314 {
336                    format!(
337                        "Access denied opening '{device_path}'.\n\
338                         FlashKraft must be run as Administrator on Windows.\n\
339                         Right-click the application and choose \
340                         'Run as administrator'."
341                    )
342                } else if raw == 32 {
343                    // ERROR_SHARING_VIOLATION
344                    format!(
345                        "Device '{device_path}' is in use by another process.\n\
346                         Close any applications using the drive and try again."
347                    )
348                } else {
349                    format!("Cannot open device '{device_path}' for writing: {e}")
350                }
351            })
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Step 1 – Unmount
357// ---------------------------------------------------------------------------
358
359fn unmount_device(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
360    let device_name = Path::new(device_path)
361        .file_name()
362        .map(|n| n.to_string_lossy().to_string())
363        .unwrap_or_default();
364
365    let partitions = find_mounted_partitions(&device_name, device_path);
366
367    if partitions.is_empty() {
368        send(tx, FlashEvent::Log("No mounted partitions found".into()));
369    } else {
370        for partition in &partitions {
371            send(tx, FlashEvent::Log(format!("Unmounting {partition}")));
372            do_unmount(partition, tx);
373        }
374    }
375}
376
377/// Returns the list of mounted partitions/volumes that belong to `device_path`.
378///
379/// On Linux/macOS this parses `/proc/mounts`.
380/// On Windows this enumerates logical drive letters, resolves each to its
381/// underlying physical device via `QueryDosDeviceW`, and returns the volume
382/// paths (e.g. `\\.\C:`) whose physical device number matches `device_path`
383/// (e.g. `\\.\PhysicalDrive1`).
384fn find_mounted_partitions(
385    #[cfg_attr(target_os = "windows", allow(unused_variables))] device_name: &str,
386    device_path: &str,
387) -> Vec<String> {
388    #[cfg(not(target_os = "windows"))]
389    {
390        let mounts = std::fs::read_to_string("/proc/mounts")
391            .or_else(|_| std::fs::read_to_string("/proc/self/mounts"))
392            .unwrap_or_default();
393
394        let mut partitions = Vec::new();
395        for line in mounts.lines() {
396            let dev = match line.split_whitespace().next() {
397                Some(d) => d,
398                None => continue,
399            };
400            if dev == device_path || is_partition_of(dev, device_name) {
401                partitions.push(dev.to_string());
402            }
403        }
404        partitions
405    }
406
407    #[cfg(target_os = "windows")]
408    {
409        windows::find_volumes_on_physical_drive(device_path)
410    }
411}
412
413#[cfg(not(target_os = "windows"))]
414fn is_partition_of(dev: &str, device_name: &str) -> bool {
415    // `dev` may be a full path like "/dev/sda1"; compare only the basename.
416    let dev_base = Path::new(dev)
417        .file_name()
418        .map(|n| n.to_string_lossy())
419        .unwrap_or_default();
420
421    if !dev_base.starts_with(device_name) {
422        return false;
423    }
424    let suffix = &dev_base[device_name.len()..];
425    if suffix.is_empty() {
426        return false;
427    }
428    let first = suffix.chars().next().unwrap();
429    first.is_ascii_digit() || (first == 'p' && suffix.len() > 1)
430}
431
432fn do_unmount(partition: &str, tx: &mpsc::Sender<FlashEvent>) {
433    #[cfg(target_os = "linux")]
434    {
435        use nix::unistd::seteuid;
436        use std::ffi::CString;
437
438        // Need root to unmount.
439        let _ = seteuid(nix::unistd::Uid::from_raw(0));
440
441        if let Ok(c_path) = CString::new(partition) {
442            let ret = unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) };
443            if ret != 0 {
444                let err = std::io::Error::last_os_error();
445                send(
446                    tx,
447                    FlashEvent::Log(format!("Warning — could not unmount {partition}: {err}")),
448                );
449            }
450        }
451
452        let _ = seteuid(real_uid());
453    }
454
455    #[cfg(target_os = "macos")]
456    {
457        let out = std::process::Command::new("diskutil")
458            .args(["unmount", partition])
459            .output();
460        if let Ok(o) = out {
461            if !o.status.success() {
462                send(
463                    tx,
464                    FlashEvent::Log(format!("Warning — diskutil unmount {partition} failed")),
465                );
466            }
467        }
468    }
469
470    // Windows: open the volume with exclusive access, lock it, then dismount.
471    // The volume path is expected to be of the form `\\.\C:` (no trailing slash).
472    #[cfg(target_os = "windows")]
473    {
474        match windows::lock_and_dismount_volume(partition) {
475            Ok(()) => send(
476                tx,
477                FlashEvent::Log(format!("Dismounted volume {partition}")),
478            ),
479            Err(e) => send(
480                tx,
481                FlashEvent::Log(format!("Warning — could not dismount {partition}: {e}")),
482            ),
483        }
484    }
485}
486
487// ---------------------------------------------------------------------------
488// Step 2 – Write image
489// ---------------------------------------------------------------------------
490
491fn write_image(
492    image_path: &str,
493    device_path: &str,
494    image_size: u64,
495    tx: &mpsc::Sender<FlashEvent>,
496    cancel: &Arc<AtomicBool>,
497) -> Result<(), String> {
498    let image_file =
499        std::fs::File::open(image_path).map_err(|e| format!("Cannot open image: {e}"))?;
500
501    let device_file = open_device_for_writing(device_path)?;
502
503    let mut reader = io::BufReader::with_capacity(BLOCK_SIZE, image_file);
504    let mut writer = io::BufWriter::with_capacity(BLOCK_SIZE, device_file);
505    let mut buf = vec![0u8; BLOCK_SIZE];
506
507    let mut bytes_written: u64 = 0;
508    let start = Instant::now();
509    let mut last_report = Instant::now();
510
511    loop {
512        // Honour cancellation requests between blocks.
513        if cancel.load(Ordering::SeqCst) {
514            return Err("Flash operation cancelled by user".to_string());
515        }
516
517        let n = reader
518            .read(&mut buf)
519            .map_err(|e| format!("Read error on image: {e}"))?;
520
521        if n == 0 {
522            break; // EOF
523        }
524
525        writer
526            .write_all(&buf[..n])
527            .map_err(|e| format!("Write error on device: {e}"))?;
528
529        bytes_written += n as u64;
530
531        let now = Instant::now();
532        if now.duration_since(last_report) >= PROGRESS_INTERVAL || bytes_written >= image_size {
533            let elapsed_s = now.duration_since(start).as_secs_f32();
534            let speed_mb_s = if elapsed_s > 0.001 {
535                (bytes_written as f32 / (1024.0 * 1024.0)) / elapsed_s
536            } else {
537                0.0
538            };
539
540            send(
541                tx,
542                FlashEvent::Progress {
543                    bytes_written,
544                    total_bytes: image_size,
545                    speed_mb_s,
546                },
547            );
548            last_report = now;
549        }
550    }
551
552    // Flush BufWriter → kernel page cache.
553    writer
554        .flush()
555        .map_err(|e| format!("Buffer flush error: {e}"))?;
556
557    // Retrieve the underlying File for fsync.
558    #[cfg_attr(not(unix), allow(unused_variables))]
559    let device_file = writer
560        .into_inner()
561        .map_err(|e| format!("BufWriter error: {e}"))?;
562
563    // fsync: push all dirty pages to the physical medium.
564    // Treated as a hard error — a failed fsync means we cannot trust the
565    // data reached the device.
566    #[cfg(unix)]
567    {
568        use std::os::unix::io::AsRawFd;
569        let fd = device_file.as_raw_fd();
570        let ret = unsafe { libc::fsync(fd) };
571        if ret != 0 {
572            let err = std::io::Error::last_os_error();
573            return Err(format!(
574                "fsync failed on '{device_path}': {err} — \
575                 data may not have been fully written to the device"
576            ));
577        }
578    }
579
580    // Emit a final progress event at 100 %.
581    let elapsed_s = start.elapsed().as_secs_f32();
582    let speed_mb_s = if elapsed_s > 0.001 {
583        (bytes_written as f32 / (1024.0 * 1024.0)) / elapsed_s
584    } else {
585        0.0
586    };
587    send(
588        tx,
589        FlashEvent::Progress {
590            bytes_written,
591            total_bytes: image_size,
592            speed_mb_s,
593        },
594    );
595
596    send(tx, FlashEvent::Log("Image write complete".into()));
597    Ok(())
598}
599
600// ---------------------------------------------------------------------------
601// Step 3 – Sync
602// ---------------------------------------------------------------------------
603
604fn sync_device(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
605    #[cfg(unix)]
606    if let Ok(f) = std::fs::OpenOptions::new().write(true).open(device_path) {
607        use std::os::unix::io::AsRawFd;
608        let fd = f.as_raw_fd();
609        #[cfg(target_os = "linux")]
610        unsafe {
611            libc::fdatasync(fd);
612        }
613        #[cfg(not(target_os = "linux"))]
614        unsafe {
615            libc::fsync(fd);
616        }
617        drop(f);
618    }
619
620    #[cfg(target_os = "linux")]
621    unsafe {
622        libc::sync();
623    }
624
625    // Windows: open the physical drive and call FlushFileBuffers.
626    // This forces the OS to flush all dirty pages for the device to hardware.
627    #[cfg(target_os = "windows")]
628    {
629        match windows::flush_device_buffers(device_path) {
630            Ok(()) => {}
631            Err(e) => send(
632                tx,
633                FlashEvent::Log(format!(
634                    "Warning — FlushFileBuffers on '{device_path}' failed: {e}"
635                )),
636            ),
637        }
638    }
639
640    send(tx, FlashEvent::Log("Write-back caches flushed".into()));
641}
642
643// ---------------------------------------------------------------------------
644// Step 4 – Re-read partition table
645// ---------------------------------------------------------------------------
646
647#[cfg(target_os = "linux")]
648fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
649    use nix::ioctl_none;
650    use std::os::unix::io::AsRawFd;
651
652    ioctl_none!(blkrrpart, 0x12, 95);
653
654    // Brief pause so any pending I/O completes before we poke the kernel.
655    std::thread::sleep(Duration::from_millis(500));
656
657    match std::fs::OpenOptions::new().write(true).open(device_path) {
658        Ok(f) => {
659            let result = unsafe { blkrrpart(f.as_raw_fd()) };
660            match result {
661                Ok(_) => send(
662                    tx,
663                    FlashEvent::Log("Kernel partition table refreshed".into()),
664                ),
665                Err(e) => send(
666                    tx,
667                    FlashEvent::Log(format!(
668                        "Warning — BLKRRPART ioctl failed \
669                         (device may not be partitioned): {e}"
670                    )),
671                ),
672            }
673        }
674        Err(e) => send(
675            tx,
676            FlashEvent::Log(format!(
677                "Warning — could not open device for BLKRRPART: {e}"
678            )),
679        ),
680    }
681}
682
683#[cfg(target_os = "macos")]
684fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
685    let _ = std::process::Command::new("diskutil")
686        .args(["rereadPartitionTable", device_path])
687        .output();
688    send(
689        tx,
690        FlashEvent::Log("Partition table refresh requested (macOS)".into()),
691    );
692}
693
694// Windows: IOCTL_DISK_UPDATE_PROPERTIES asks the partition manager to
695// re-enumerate the partition table from the on-disk data.
696#[cfg(target_os = "windows")]
697fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
698    // Brief pause so the OS flushes before we poke the partition manager.
699    std::thread::sleep(Duration::from_millis(500));
700
701    match windows::update_disk_properties(device_path) {
702        Ok(()) => send(
703            tx,
704            FlashEvent::Log("Partition table refreshed (IOCTL_DISK_UPDATE_PROPERTIES)".into()),
705        ),
706        Err(e) => send(
707            tx,
708            FlashEvent::Log(format!(
709                "Warning — IOCTL_DISK_UPDATE_PROPERTIES failed: {e}"
710            )),
711        ),
712    }
713}
714
715#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
716fn reread_partition_table(_device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
717    send(
718        tx,
719        FlashEvent::Log("Partition table refresh not supported on this platform".into()),
720    );
721}
722
723// ---------------------------------------------------------------------------
724// Step 5 – Verify
725// ---------------------------------------------------------------------------
726
727fn verify(
728    image_path: &str,
729    device_path: &str,
730    image_size: u64,
731    tx: &mpsc::Sender<FlashEvent>,
732) -> Result<(), String> {
733    send(
734        tx,
735        FlashEvent::Log("Computing SHA-256 of source image".into()),
736    );
737    let image_hash = sha256_first_n_bytes(image_path, image_size)?;
738
739    send(
740        tx,
741        FlashEvent::Log(format!(
742            "Reading back {image_size} bytes from device for verification"
743        )),
744    );
745    let device_hash = sha256_first_n_bytes(device_path, image_size)?;
746
747    if image_hash != device_hash {
748        return Err(format!(
749            "Verification failed — data mismatch \
750             (image={image_hash} device={device_hash})"
751        ));
752    }
753
754    send(
755        tx,
756        FlashEvent::Log(format!("Verification passed ({image_hash})")),
757    );
758    Ok(())
759}
760
761fn sha256_first_n_bytes(path: &str, max_bytes: u64) -> Result<String, String> {
762    use sha2::{Digest, Sha256};
763
764    let file =
765        std::fs::File::open(path).map_err(|e| format!("Cannot open {path} for hashing: {e}"))?;
766
767    let mut hasher = Sha256::new();
768    let mut reader = io::BufReader::with_capacity(BLOCK_SIZE, file);
769    let mut buf = vec![0u8; BLOCK_SIZE];
770    let mut remaining = max_bytes;
771
772    while remaining > 0 {
773        let to_read = (remaining as usize).min(buf.len());
774        let n = reader
775            .read(&mut buf[..to_read])
776            .map_err(|e| format!("Read error while hashing {path}: {e}"))?;
777        if n == 0 {
778            break;
779        }
780        hasher.update(&buf[..n]);
781        remaining -= n as u64;
782    }
783
784    Ok(format!("{:x}", hasher.finalize()))
785}
786
787// ---------------------------------------------------------------------------
788// Windows implementation helpers
789// ---------------------------------------------------------------------------
790
791/// All Windows-specific raw-device operations are collected here.
792///
793/// ## Privilege
794/// The binary must be run as Administrator (the UAC manifest embedded by
795/// `build.rs` ensures Windows prompts for elevation on launch).  Raw physical
796/// drive access (`\\.\PhysicalDriveN`) and volume lock/dismount both require
797/// the `SeManageVolumePrivilege` that is only present in an elevated token.
798///
799/// ## Volume vs physical drive paths
800/// - **Physical drive**: `\\.\PhysicalDrive0`, `\\.\PhysicalDrive1`, …
801///   Used for writing the image, flushing, and partition-table refresh.
802/// - **Volume (drive letter)**: `\\.\C:`, `\\.\D:`, …
803///   Used for locking and dismounting before we write.
804#[cfg(target_os = "windows")]
805mod windows {
806    // ── Win32 type aliases ────────────────────────────────────────────────────
807    // windows-sys uses raw C types; give them readable names.
808    use windows_sys::Win32::{
809        Foundation::{
810            CloseHandle, FALSE, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
811        },
812        Storage::FileSystem::{
813            CreateFileW, FlushFileBuffers, FILE_FLAG_WRITE_THROUGH, FILE_SHARE_READ,
814            FILE_SHARE_WRITE, OPEN_EXISTING,
815        },
816        System::{
817            Ioctl::{FSCTL_DISMOUNT_VOLUME, FSCTL_LOCK_VOLUME, IOCTL_DISK_UPDATE_PROPERTIES},
818            IO::DeviceIoControl,
819        },
820    };
821
822    // ── Helpers ───────────────────────────────────────────────────────────────
823
824    /// Encode a Rust `&str` as a null-terminated UTF-16 `Vec<u16>`.
825    fn to_wide(s: &str) -> Vec<u16> {
826        use std::os::windows::ffi::OsStrExt;
827        std::ffi::OsStr::new(s)
828            .encode_wide()
829            .chain(std::iter::once(0))
830            .collect()
831    }
832
833    /// Open a device path (`\\.\PhysicalDriveN` or `\\.\C:`) and return its
834    /// Win32 `HANDLE`.  The handle must be closed with `CloseHandle` when done.
835    ///
836    /// `access` should be `GENERIC_READ`, `GENERIC_WRITE`, or both OR-ed.
837    fn open_device_handle(path: &str, access: u32) -> Result<HANDLE, String> {
838        let wide = to_wide(path);
839        let handle = unsafe {
840            CreateFileW(
841                wide.as_ptr(),
842                access,
843                FILE_SHARE_READ | FILE_SHARE_WRITE,
844                std::ptr::null(),
845                OPEN_EXISTING,
846                FILE_FLAG_WRITE_THROUGH,
847                std::ptr::null_mut(),
848            )
849        };
850        if handle == INVALID_HANDLE_VALUE {
851            Err(format!(
852                "Cannot open device '{}': {}",
853                path,
854                std::io::Error::last_os_error()
855            ))
856        } else {
857            Ok(handle)
858        }
859    }
860
861    /// Issue a simple `DeviceIoControl` call with no input or output buffer.
862    ///
863    /// Returns `Ok(())` on success, or an `Err` with the Win32 error message.
864    fn device_ioctl(handle: HANDLE, code: u32) -> Result<(), String> {
865        let mut bytes_returned: u32 = 0;
866        let ok = unsafe {
867            DeviceIoControl(
868                handle,
869                code,
870                std::ptr::null(), // no input buffer
871                0,
872                std::ptr::null_mut(), // no output buffer
873                0,
874                &mut bytes_returned,
875                std::ptr::null_mut(), // synchronous (no OVERLAPPED)
876            )
877        };
878        if ok == FALSE {
879            Err(format!("{}", std::io::Error::last_os_error()))
880        } else {
881            Ok(())
882        }
883    }
884
885    // ── Public helpers called from flash_helper ───────────────────────────────
886
887    /// Enumerate all logical drive letters whose underlying physical device
888    /// path matches `physical_drive` (e.g. `\\.\PhysicalDrive1`).
889    ///
890    /// Returns a list of volume paths suitable for passing to
891    /// `lock_and_dismount_volume`, e.g. `["\\.\C:", "\\.\D:"]`.
892    ///
893    /// Algorithm:
894    /// 1. Obtain the physical drive number from `physical_drive`.
895    /// 2. Call `GetLogicalDriveStringsW` to list all drive letters.
896    /// 3. For each letter, open the volume and call `IOCTL_STORAGE_GET_DEVICE_NUMBER`
897    ///    to get its physical drive number.
898    /// 4. Collect those whose number matches.
899    pub fn find_volumes_on_physical_drive(physical_drive: &str) -> Vec<String> {
900        use windows_sys::Win32::{
901            Storage::FileSystem::GetLogicalDriveStringsW,
902            System::Ioctl::{IOCTL_STORAGE_GET_DEVICE_NUMBER, STORAGE_DEVICE_NUMBER},
903        };
904
905        // Extract the drive index from "\\.\PhysicalDriveN".
906        let target_index: u32 = physical_drive
907            .to_ascii_lowercase()
908            .trim_start_matches(r"\\.\physicaldrive")
909            .parse()
910            .unwrap_or(u32::MAX);
911
912        // Get all logical drive strings ("C:\", "D:\", …).
913        let mut buf = vec![0u16; 512];
914        let len = unsafe { GetLogicalDriveStringsW(buf.len() as u32, buf.as_mut_ptr()) };
915        if len == 0 || len > buf.len() as u32 {
916            return Vec::new();
917        }
918
919        // Parse the null-separated, double-null-terminated list.
920        let drive_letters: Vec<String> = buf[..len as usize]
921            .split(|&c| c == 0)
922            .filter(|s| !s.is_empty())
923            .map(|s| {
924                // "C:\" → "\\.\C:"  (no trailing backslash — required for
925                // CreateFileW on a volume)
926                let letter: String = std::char::from_u32(s[0] as u32)
927                    .map(|c| c.to_string())
928                    .unwrap_or_default();
929                format!(r"\\.\{}:", letter)
930            })
931            .collect();
932
933        let mut matching = Vec::new();
934
935        for vol_path in &drive_letters {
936            let wide = to_wide(vol_path);
937            let handle = unsafe {
938                CreateFileW(
939                    wide.as_ptr(),
940                    GENERIC_READ,
941                    FILE_SHARE_READ | FILE_SHARE_WRITE,
942                    std::ptr::null(),
943                    OPEN_EXISTING,
944                    0,
945                    std::ptr::null_mut(),
946                )
947            };
948            if handle == INVALID_HANDLE_VALUE {
949                continue;
950            }
951
952            let mut dev_num = STORAGE_DEVICE_NUMBER {
953                DeviceType: 0,
954                DeviceNumber: u32::MAX,
955                PartitionNumber: 0,
956            };
957            let mut bytes_returned: u32 = 0;
958
959            let ok = unsafe {
960                DeviceIoControl(
961                    handle,
962                    IOCTL_STORAGE_GET_DEVICE_NUMBER,
963                    std::ptr::null(),
964                    0,
965                    &mut dev_num as *mut _ as *mut _,
966                    std::mem::size_of::<STORAGE_DEVICE_NUMBER>() as u32,
967                    &mut bytes_returned,
968                    std::ptr::null_mut(),
969                )
970            };
971
972            unsafe { CloseHandle(handle) };
973
974            if ok != FALSE && dev_num.DeviceNumber == target_index {
975                matching.push(vol_path.clone());
976            }
977        }
978
979        matching
980    }
981
982    /// Lock a volume exclusively and dismount it so writes to the underlying
983    /// physical disk can proceed without the filesystem intercepting I/O.
984    ///
985    /// Steps (mirrors what Rufus / dd for Windows do):
986    /// 1. Open the volume with `GENERIC_READ | GENERIC_WRITE`.
987    /// 2. `FSCTL_LOCK_VOLUME`   — exclusive lock; fails if files are open.
988    /// 3. `FSCTL_DISMOUNT_VOLUME` — tell the FS driver to flush and detach.
989    ///
990    /// The lock is held for the lifetime of the handle.  Because we close the
991    /// handle immediately after dismounting, the volume is automatically
992    /// unlocked (`FSCTL_UNLOCK_VOLUME` is implicit on handle close).
993    pub fn lock_and_dismount_volume(volume_path: &str) -> Result<(), String> {
994        let handle = open_device_handle(volume_path, GENERIC_READ | GENERIC_WRITE)?;
995
996        // Lock — exclusive; if this fails (files open) we still try to
997        // dismount because the user may have opened Explorer on the drive.
998        let lock_result = device_ioctl(handle, FSCTL_LOCK_VOLUME);
999        if let Err(ref e) = lock_result {
1000            // Non-fatal: log and continue.  Dismount can still succeed.
1001            eprintln!(
1002                "[flash] FSCTL_LOCK_VOLUME on '{volume_path}' failed ({e}); \
1003                 attempting dismount anyway"
1004            );
1005        }
1006
1007        // Dismount — detaches the filesystem; flushes dirty data first.
1008        let dismount_result = device_ioctl(handle, FSCTL_DISMOUNT_VOLUME);
1009
1010        unsafe { CloseHandle(handle) };
1011
1012        lock_result.and(dismount_result)
1013    }
1014
1015    /// Call `FlushFileBuffers` on the physical drive to force the OS to push
1016    /// all dirty write-back pages to the device hardware.
1017    pub fn flush_device_buffers(device_path: &str) -> Result<(), String> {
1018        let handle = open_device_handle(device_path, GENERIC_WRITE)?;
1019        let ok = unsafe { FlushFileBuffers(handle) };
1020        unsafe { CloseHandle(handle) };
1021        if ok == FALSE {
1022            Err(format!("{}", std::io::Error::last_os_error()))
1023        } else {
1024            Ok(())
1025        }
1026    }
1027
1028    /// Send `IOCTL_DISK_UPDATE_PROPERTIES` to the physical drive, asking the
1029    /// Windows partition manager to re-read the partition table from disk.
1030    pub fn update_disk_properties(device_path: &str) -> Result<(), String> {
1031        let handle = open_device_handle(device_path, GENERIC_READ | GENERIC_WRITE)?;
1032        let result = device_ioctl(handle, IOCTL_DISK_UPDATE_PROPERTIES);
1033        unsafe { CloseHandle(handle) };
1034        result
1035    }
1036
1037    // ── Unit tests ────────────────────────────────────────────────────────────
1038
1039    #[cfg(test)]
1040    mod tests {
1041        use super::*;
1042
1043        /// `to_wide` must produce a null-terminated UTF-16 sequence.
1044        #[test]
1045        fn test_to_wide_null_terminated() {
1046            let wide = to_wide("ABC");
1047            assert_eq!(wide.last(), Some(&0u16), "must be null-terminated");
1048            assert_eq!(&wide[..3], &[b'A' as u16, b'B' as u16, b'C' as u16]);
1049        }
1050
1051        /// `to_wide` on an empty string produces exactly one null.
1052        #[test]
1053        fn test_to_wide_empty() {
1054            let wide = to_wide("");
1055            assert_eq!(wide, vec![0u16]);
1056        }
1057
1058        /// `open_device_handle` on a nonexistent path must return an error.
1059        #[test]
1060        fn test_open_device_handle_bad_path_returns_error() {
1061            let result = open_device_handle(r"\\.\NonExistentDevice999", GENERIC_READ);
1062            assert!(result.is_err(), "expected error for nonexistent device");
1063        }
1064
1065        /// `flush_device_buffers` on a nonexistent drive must return an error.
1066        #[test]
1067        fn test_flush_device_buffers_bad_path() {
1068            let result = flush_device_buffers(r"\\.\PhysicalDrive999");
1069            assert!(result.is_err());
1070        }
1071
1072        /// `update_disk_properties` on a nonexistent drive must return an error.
1073        #[test]
1074        fn test_update_disk_properties_bad_path() {
1075            let result = update_disk_properties(r"\\.\PhysicalDrive999");
1076            assert!(result.is_err());
1077        }
1078
1079        /// `lock_and_dismount_volume` on a nonexistent path must return an error.
1080        #[test]
1081        fn test_lock_and_dismount_bad_path() {
1082            let result = lock_and_dismount_volume(r"\\.\Z99:");
1083            assert!(result.is_err());
1084        }
1085
1086        /// `find_volumes_on_physical_drive` with an unparseable path should
1087        /// return an empty Vec (no panic).
1088        #[test]
1089        fn test_find_volumes_bad_path_no_panic() {
1090            let result = find_volumes_on_physical_drive("not-a-valid-path");
1091            // May be empty or contain volumes; must not panic.
1092            let _ = result;
1093        }
1094
1095        /// `find_volumes_on_physical_drive` for a very high drive number
1096        /// (almost certainly nonexistent) should return an empty list.
1097        #[test]
1098        fn test_find_volumes_nonexistent_drive_returns_empty() {
1099            let result = find_volumes_on_physical_drive(r"\\.\PhysicalDrive999");
1100            assert!(
1101                result.is_empty(),
1102                "expected no volumes for PhysicalDrive999"
1103            );
1104        }
1105    }
1106}
1107
1108// ---------------------------------------------------------------------------
1109// Tests
1110// ---------------------------------------------------------------------------
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use std::io::Write;
1116    use std::sync::mpsc;
1117
1118    fn make_channel() -> (mpsc::Sender<FlashEvent>, mpsc::Receiver<FlashEvent>) {
1119        mpsc::channel()
1120    }
1121
1122    fn drain(rx: &mpsc::Receiver<FlashEvent>) -> Vec<FlashEvent> {
1123        let mut events = Vec::new();
1124        while let Ok(e) = rx.try_recv() {
1125            events.push(e);
1126        }
1127        events
1128    }
1129
1130    fn has_stage(events: &[FlashEvent], stage: &FlashStage) -> bool {
1131        events
1132            .iter()
1133            .any(|e| matches!(e, FlashEvent::Stage(s) if s == stage))
1134    }
1135
1136    fn find_error(events: &[FlashEvent]) -> Option<&str> {
1137        events.iter().find_map(|e| {
1138            if let FlashEvent::Error(msg) = e {
1139                Some(msg.as_str())
1140            } else {
1141                None
1142            }
1143        })
1144    }
1145
1146    // ── set_real_uid ────────────────────────────────────────────────────────
1147
1148    #[test]
1149    fn test_set_real_uid_stores_value() {
1150        // OnceLock only sets once; in tests the first call wins.
1151        // Just verify it doesn't panic.
1152        set_real_uid(1000);
1153    }
1154
1155    // ── is_partition_of ─────────────────────────────────────────────────────
1156
1157    #[test]
1158    #[cfg(not(target_os = "windows"))]
1159    fn test_is_partition_of_sda() {
1160        assert!(is_partition_of("/dev/sda1", "sda"));
1161        assert!(is_partition_of("/dev/sda2", "sda"));
1162        assert!(!is_partition_of("/dev/sdb1", "sda"));
1163        assert!(!is_partition_of("/dev/sda", "sda"));
1164    }
1165
1166    #[test]
1167    #[cfg(not(target_os = "windows"))]
1168    fn test_is_partition_of_nvme() {
1169        assert!(is_partition_of("/dev/nvme0n1p1", "nvme0n1"));
1170        assert!(is_partition_of("/dev/nvme0n1p2", "nvme0n1"));
1171        assert!(!is_partition_of("/dev/nvme0n1", "nvme0n1"));
1172    }
1173
1174    #[test]
1175    #[cfg(not(target_os = "windows"))]
1176    fn test_is_partition_of_mmcblk() {
1177        assert!(is_partition_of("/dev/mmcblk0p1", "mmcblk0"));
1178        assert!(!is_partition_of("/dev/mmcblk0", "mmcblk0"));
1179    }
1180
1181    #[test]
1182    #[cfg(not(target_os = "windows"))]
1183    fn test_is_partition_of_no_false_prefix_match() {
1184        assert!(!is_partition_of("/dev/sda1", "sd"));
1185    }
1186
1187    // ── reject_partition_node ───────────────────────────────────────────────
1188
1189    #[test]
1190    #[cfg(target_os = "linux")]
1191    fn test_reject_partition_node_sda1() {
1192        let dir = std::env::temp_dir();
1193        let img = dir.join("fk_reject_img.bin");
1194        std::fs::write(&img, vec![0u8; 1024]).unwrap();
1195
1196        let result = reject_partition_node("/dev/sda1");
1197        assert!(result.is_err());
1198        assert!(result.unwrap_err().contains("Refusing"));
1199
1200        let _ = std::fs::remove_file(img);
1201    }
1202
1203    #[test]
1204    #[cfg(target_os = "linux")]
1205    fn test_reject_partition_node_nvme() {
1206        let result = reject_partition_node("/dev/nvme0n1p1");
1207        assert!(result.is_err());
1208        assert!(result.unwrap_err().contains("Refusing"));
1209    }
1210
1211    #[test]
1212    #[cfg(target_os = "linux")]
1213    fn test_reject_partition_node_accepts_whole_disk() {
1214        // /dev/sdb does not exist in CI — the function only checks the name
1215        // pattern, not whether the path exists.
1216        let result = reject_partition_node("/dev/sdb");
1217        assert!(result.is_ok(), "whole-disk node should not be rejected");
1218    }
1219
1220    // ── find_mounted_partitions ──────────────────────────────────────────────
1221
1222    #[test]
1223    fn test_find_mounted_partitions_parses_proc_mounts_format() {
1224        // We cannot mock /proc/mounts in a unit test so we just verify the
1225        // function doesn't panic and returns a Vec.
1226        let result = find_mounted_partitions("sda", "/dev/sda");
1227        let _ = result; // any result is valid
1228    }
1229
1230    // ── sha256_first_n_bytes ─────────────────────────────────────────────────
1231
1232    #[test]
1233    fn test_sha256_full_file() {
1234        use sha2::{Digest, Sha256};
1235
1236        let dir = std::env::temp_dir();
1237        let path = dir.join("fk_sha256_full.bin");
1238        let data: Vec<u8> = (0u8..=255u8).cycle().take(4096).collect();
1239        std::fs::write(&path, &data).unwrap();
1240
1241        let result = sha256_first_n_bytes(path.to_str().unwrap(), data.len() as u64).unwrap();
1242        let expected = format!("{:x}", Sha256::digest(&data));
1243        assert_eq!(result, expected);
1244
1245        let _ = std::fs::remove_file(path);
1246    }
1247
1248    #[test]
1249    fn test_sha256_partial() {
1250        use sha2::{Digest, Sha256};
1251
1252        let dir = std::env::temp_dir();
1253        let path = dir.join("fk_sha256_partial.bin");
1254        let data: Vec<u8> = (0u8..=255u8).cycle().take(8192).collect();
1255        std::fs::write(&path, &data).unwrap();
1256
1257        let n = 4096u64;
1258        let result = sha256_first_n_bytes(path.to_str().unwrap(), n).unwrap();
1259        let expected = format!("{:x}", Sha256::digest(&data[..n as usize]));
1260        assert_eq!(result, expected);
1261
1262        let _ = std::fs::remove_file(path);
1263    }
1264
1265    #[test]
1266    fn test_sha256_nonexistent_returns_error() {
1267        let result = sha256_first_n_bytes("/nonexistent/path.bin", 1024);
1268        assert!(result.is_err());
1269        assert!(result.unwrap_err().contains("Cannot open"));
1270    }
1271
1272    #[test]
1273    fn test_sha256_empty_read_is_hash_of_empty() {
1274        use sha2::{Digest, Sha256};
1275
1276        let dir = std::env::temp_dir();
1277        let path = dir.join("fk_sha256_empty.bin");
1278        std::fs::write(&path, b"hello world extended data").unwrap();
1279
1280        // max_bytes = 0 → nothing is read → hash of empty input
1281        let result = sha256_first_n_bytes(path.to_str().unwrap(), 0).unwrap();
1282        let expected = format!("{:x}", Sha256::digest(b""));
1283        assert_eq!(result, expected);
1284
1285        let _ = std::fs::remove_file(path);
1286    }
1287
1288    // ── write_image (via temp files) ─────────────────────────────────────────
1289
1290    #[test]
1291    fn test_write_image_to_temp_file() {
1292        let dir = std::env::temp_dir();
1293        let img_path = dir.join("fk_write_img.bin");
1294        let dev_path = dir.join("fk_write_dev.bin");
1295
1296        let image_size: u64 = 2 * 1024 * 1024; // 2 MiB
1297        {
1298            let mut f = std::fs::File::create(&img_path).unwrap();
1299            let block: Vec<u8> = (0u8..=255u8).cycle().take(BLOCK_SIZE).collect();
1300            let mut rem = image_size;
1301            while rem > 0 {
1302                let n = rem.min(BLOCK_SIZE as u64) as usize;
1303                f.write_all(&block[..n]).unwrap();
1304                rem -= n as u64;
1305            }
1306        }
1307        std::fs::File::create(&dev_path).unwrap();
1308
1309        let (tx, rx) = make_channel();
1310        let cancel = Arc::new(AtomicBool::new(false));
1311
1312        let result = write_image(
1313            img_path.to_str().unwrap(),
1314            dev_path.to_str().unwrap(),
1315            image_size,
1316            &tx,
1317            &cancel,
1318        );
1319
1320        assert!(result.is_ok(), "write_image failed: {result:?}");
1321
1322        let written = std::fs::read(&dev_path).unwrap();
1323        let original = std::fs::read(&img_path).unwrap();
1324        assert_eq!(written, original, "written data must match image exactly");
1325
1326        let events = drain(&rx);
1327        let has_progress = events
1328            .iter()
1329            .any(|e| matches!(e, FlashEvent::Progress { .. }));
1330        assert!(has_progress, "must emit at least one Progress event");
1331
1332        let _ = std::fs::remove_file(img_path);
1333        let _ = std::fs::remove_file(dev_path);
1334    }
1335
1336    #[test]
1337    fn test_write_image_cancelled_mid_write() {
1338        let dir = std::env::temp_dir();
1339        let img_path = dir.join("fk_cancel_img.bin");
1340        let dev_path = dir.join("fk_cancel_dev.bin");
1341
1342        // Large enough that we definitely hit the cancel check.
1343        let image_size: u64 = 8 * 1024 * 1024; // 8 MiB
1344        {
1345            let mut f = std::fs::File::create(&img_path).unwrap();
1346            let block = vec![0xAAu8; BLOCK_SIZE];
1347            let mut rem = image_size;
1348            while rem > 0 {
1349                let n = rem.min(BLOCK_SIZE as u64) as usize;
1350                f.write_all(&block[..n]).unwrap();
1351                rem -= n as u64;
1352            }
1353        }
1354        std::fs::File::create(&dev_path).unwrap();
1355
1356        let (tx, _rx) = make_channel();
1357        let cancel = Arc::new(AtomicBool::new(true)); // pre-cancelled
1358
1359        let result = write_image(
1360            img_path.to_str().unwrap(),
1361            dev_path.to_str().unwrap(),
1362            image_size,
1363            &tx,
1364            &cancel,
1365        );
1366
1367        assert!(result.is_err());
1368        assert!(
1369            result.unwrap_err().contains("cancelled"),
1370            "error should mention cancellation"
1371        );
1372
1373        let _ = std::fs::remove_file(img_path);
1374        let _ = std::fs::remove_file(dev_path);
1375    }
1376
1377    #[test]
1378    fn test_write_image_missing_image_returns_error() {
1379        let dir = std::env::temp_dir();
1380        let dev_path = dir.join("fk_noimg_dev.bin");
1381        std::fs::File::create(&dev_path).unwrap();
1382
1383        let (tx, _rx) = make_channel();
1384        let cancel = Arc::new(AtomicBool::new(false));
1385
1386        let result = write_image(
1387            "/nonexistent/image.img",
1388            dev_path.to_str().unwrap(),
1389            1024,
1390            &tx,
1391            &cancel,
1392        );
1393
1394        assert!(result.is_err());
1395        assert!(result.unwrap_err().contains("Cannot open image"));
1396
1397        let _ = std::fs::remove_file(dev_path);
1398    }
1399
1400    // ── verify ───────────────────────────────────────────────────────────────
1401
1402    #[test]
1403    fn test_verify_matching_files() {
1404        let dir = std::env::temp_dir();
1405        let img = dir.join("fk_verify_img.bin");
1406        let dev = dir.join("fk_verify_dev.bin");
1407        let data = vec![0xBBu8; 64 * 1024];
1408        std::fs::write(&img, &data).unwrap();
1409        std::fs::write(&dev, &data).unwrap();
1410
1411        let (tx, _rx) = make_channel();
1412        let result = verify(
1413            img.to_str().unwrap(),
1414            dev.to_str().unwrap(),
1415            data.len() as u64,
1416            &tx,
1417        );
1418        assert!(result.is_ok());
1419
1420        let _ = std::fs::remove_file(img);
1421        let _ = std::fs::remove_file(dev);
1422    }
1423
1424    #[test]
1425    fn test_verify_mismatch_returns_error() {
1426        let dir = std::env::temp_dir();
1427        let img = dir.join("fk_mismatch_img.bin");
1428        let dev = dir.join("fk_mismatch_dev.bin");
1429        std::fs::write(&img, vec![0x00u8; 64 * 1024]).unwrap();
1430        std::fs::write(&dev, vec![0xFFu8; 64 * 1024]).unwrap();
1431
1432        let (tx, _rx) = make_channel();
1433        let result = verify(img.to_str().unwrap(), dev.to_str().unwrap(), 64 * 1024, &tx);
1434        assert!(result.is_err());
1435        assert!(result.unwrap_err().contains("Verification failed"));
1436
1437        let _ = std::fs::remove_file(img);
1438        let _ = std::fs::remove_file(dev);
1439    }
1440
1441    #[test]
1442    fn test_verify_only_checks_image_size_bytes() {
1443        let dir = std::env::temp_dir();
1444        let img = dir.join("fk_trunc_img.bin");
1445        let dev = dir.join("fk_trunc_dev.bin");
1446        let image_data = vec![0xCCu8; 32 * 1024];
1447        let mut device_data = image_data.clone();
1448        device_data.extend_from_slice(&[0xDDu8; 32 * 1024]);
1449        std::fs::write(&img, &image_data).unwrap();
1450        std::fs::write(&dev, &device_data).unwrap();
1451
1452        let (tx, _rx) = make_channel();
1453        let result = verify(
1454            img.to_str().unwrap(),
1455            dev.to_str().unwrap(),
1456            image_data.len() as u64,
1457            &tx,
1458        );
1459        assert!(
1460            result.is_ok(),
1461            "should pass when first N bytes match: {result:?}"
1462        );
1463
1464        let _ = std::fs::remove_file(img);
1465        let _ = std::fs::remove_file(dev);
1466    }
1467
1468    // ── flash_pipeline validation ────────────────────────────────────────────
1469
1470    #[test]
1471    fn test_pipeline_rejects_missing_image() {
1472        let (tx, rx) = make_channel();
1473        let cancel = Arc::new(AtomicBool::new(false));
1474        run_pipeline("/nonexistent/image.iso", "/dev/null", tx, cancel);
1475        let events = drain(&rx);
1476        let err = find_error(&events);
1477        assert!(err.is_some(), "must emit an Error event");
1478        assert!(err.unwrap().contains("Image file not found"), "err={err:?}");
1479    }
1480
1481    #[test]
1482    fn test_pipeline_rejects_empty_image() {
1483        let dir = std::env::temp_dir();
1484        let empty = dir.join("fk_empty.img");
1485        std::fs::write(&empty, b"").unwrap();
1486
1487        let (tx, rx) = make_channel();
1488        let cancel = Arc::new(AtomicBool::new(false));
1489        run_pipeline(empty.to_str().unwrap(), "/dev/null", tx, cancel);
1490
1491        let events = drain(&rx);
1492        let err = find_error(&events);
1493        assert!(err.is_some());
1494        assert!(err.unwrap().contains("empty"), "err={err:?}");
1495
1496        let _ = std::fs::remove_file(empty);
1497    }
1498
1499    #[test]
1500    fn test_pipeline_rejects_missing_device() {
1501        let dir = std::env::temp_dir();
1502        let img = dir.join("fk_nodev_img.bin");
1503        std::fs::write(&img, vec![0u8; 1024]).unwrap();
1504
1505        let (tx, rx) = make_channel();
1506        let cancel = Arc::new(AtomicBool::new(false));
1507        run_pipeline(img.to_str().unwrap(), "/nonexistent/device", tx, cancel);
1508
1509        let events = drain(&rx);
1510        let err = find_error(&events);
1511        assert!(err.is_some());
1512        assert!(
1513            err.unwrap().contains("Target device not found"),
1514            "err={err:?}"
1515        );
1516
1517        let _ = std::fs::remove_file(img);
1518    }
1519
1520    /// End-to-end pipeline test using only temp files (no real hardware).
1521    #[test]
1522    fn test_pipeline_end_to_end_temp_files() {
1523        let dir = std::env::temp_dir();
1524        let img = dir.join("fk_e2e_img.bin");
1525        let dev = dir.join("fk_e2e_dev.bin");
1526
1527        let image_data: Vec<u8> = (0u8..=255u8).cycle().take(1024 * 1024).collect();
1528        std::fs::write(&img, &image_data).unwrap();
1529        std::fs::File::create(&dev).unwrap();
1530
1531        let (tx, rx) = make_channel();
1532        let cancel = Arc::new(AtomicBool::new(false));
1533        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
1534
1535        let events = drain(&rx);
1536
1537        // Must have seen at least one Progress event.
1538        let has_progress = events
1539            .iter()
1540            .any(|e| matches!(e, FlashEvent::Progress { .. }));
1541        assert!(has_progress, "must emit Progress events");
1542
1543        // Must have passed through the core pipeline stages.
1544        assert!(
1545            has_stage(&events, &FlashStage::Unmounting),
1546            "must emit Unmounting stage"
1547        );
1548        assert!(
1549            has_stage(&events, &FlashStage::Writing),
1550            "must emit Writing stage"
1551        );
1552        assert!(
1553            has_stage(&events, &FlashStage::Syncing),
1554            "must emit Syncing stage"
1555        );
1556
1557        // On temp files the pipeline either completes (Done) or fails after
1558        // the write/verify stage (e.g. BLKRRPART on a regular file).
1559        let has_done = events.iter().any(|e| matches!(e, FlashEvent::Done));
1560        let has_error = events.iter().any(|e| matches!(e, FlashEvent::Error(_)));
1561        assert!(
1562            has_done || has_error,
1563            "pipeline must end with Done or Error"
1564        );
1565
1566        if has_done {
1567            let written = std::fs::read(&dev).unwrap();
1568            assert_eq!(written, image_data, "written data must match image");
1569        } else if let Some(err_msg) = find_error(&events) {
1570            // Error must NOT be from write or verify.
1571            assert!(
1572                !err_msg.contains("Cannot open")
1573                    && !err_msg.contains("Verification failed")
1574                    && !err_msg.contains("Write error"),
1575                "unexpected error: {err_msg}"
1576            );
1577        }
1578
1579        let _ = std::fs::remove_file(img);
1580        let _ = std::fs::remove_file(dev);
1581    }
1582
1583    // ── FlashStage Display ───────────────────────────────────────────────────
1584
1585    #[test]
1586    fn test_flash_stage_display() {
1587        assert!(FlashStage::Writing.to_string().contains("Writing"));
1588        assert!(FlashStage::Syncing.to_string().contains("Flushing"));
1589        assert!(FlashStage::Done.to_string().contains("complete"));
1590        assert!(FlashStage::Failed("oops".into())
1591            .to_string()
1592            .contains("oops"));
1593    }
1594
1595    // ── FlashStage equality ──────────────────────────────────────────────────
1596
1597    #[test]
1598    fn test_flash_stage_eq() {
1599        assert_eq!(FlashStage::Writing, FlashStage::Writing);
1600        assert_ne!(FlashStage::Writing, FlashStage::Syncing);
1601        assert_eq!(
1602            FlashStage::Failed("x".into()),
1603            FlashStage::Failed("x".into())
1604        );
1605        assert_ne!(
1606            FlashStage::Failed("x".into()),
1607            FlashStage::Failed("y".into())
1608        );
1609    }
1610
1611    // ── FlashEvent Clone ─────────────────────────────────────────────────────
1612
1613    #[test]
1614    fn test_flash_event_clone() {
1615        let events = vec![
1616            FlashEvent::Stage(FlashStage::Writing),
1617            FlashEvent::Progress {
1618                bytes_written: 1024,
1619                total_bytes: 4096,
1620                speed_mb_s: 12.5,
1621            },
1622            FlashEvent::Log("hello".into()),
1623            FlashEvent::Done,
1624            FlashEvent::Error("boom".into()),
1625        ];
1626        for e in &events {
1627            let _ = e.clone(); // must not panic
1628        }
1629    }
1630
1631    // ── find_mounted_partitions (platform-neutral contracts) ─────────────────
1632
1633    /// Calling find_mounted_partitions with a device name that almost
1634    /// certainly isn't mounted must return an empty Vec without panicking.
1635    #[test]
1636    fn test_find_mounted_partitions_nonexistent_device_returns_empty() {
1637        // PhysicalDrive999 / sdzzz are both guaranteed not to exist anywhere.
1638        #[cfg(target_os = "windows")]
1639        let result = find_mounted_partitions("PhysicalDrive999", r"\\.\PhysicalDrive999");
1640        #[cfg(not(target_os = "windows"))]
1641        let result = find_mounted_partitions("sdzzz", "/dev/sdzzz");
1642
1643        // Result can be empty or non-empty depending on the OS, but must not panic.
1644        let _ = result;
1645    }
1646
1647    /// find_mounted_partitions must return a Vec (never panic) even when
1648    /// called with an empty device name.
1649    #[test]
1650    fn test_find_mounted_partitions_empty_name_no_panic() {
1651        let result = find_mounted_partitions("", "");
1652        let _ = result;
1653    }
1654
1655    // ── is_partition_of (Windows drive-letter paths are not partitions) ──────
1656
1657    /// On Windows the caller never passes Unix-style paths, so these should
1658    /// all return false (no false positives from the partition-suffix logic).
1659    #[test]
1660    fn test_is_partition_of_windows_style_paths() {
1661        // Windows physical drive paths have no numeric suffix after the name.
1662        assert!(!is_partition_of(r"\\.\PhysicalDrive0", "PhysicalDrive0"));
1663        assert!(!is_partition_of(r"\\.\PhysicalDrive1", "PhysicalDrive0"));
1664    }
1665
1666    // ── sync_device (via pipeline — emits Log event on all platforms) ────────
1667
1668    /// sync_device must emit a "caches flushed" log event regardless of
1669    /// platform.  We test this indirectly via the full pipeline on temp files.
1670    #[test]
1671    fn test_pipeline_emits_syncing_stage() {
1672        let dir = std::env::temp_dir();
1673        let img = dir.join("fk_sync_stage_img.bin");
1674        let dev = dir.join("fk_sync_stage_dev.bin");
1675
1676        let data: Vec<u8> = (0u8..=255).cycle().take(512 * 1024).collect();
1677        std::fs::write(&img, &data).unwrap();
1678        std::fs::File::create(&dev).unwrap();
1679
1680        let (tx, rx) = make_channel();
1681        let cancel = Arc::new(AtomicBool::new(false));
1682        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
1683
1684        let events = drain(&rx);
1685        assert!(
1686            has_stage(&events, &FlashStage::Syncing),
1687            "Syncing stage must be emitted on every platform"
1688        );
1689
1690        let _ = std::fs::remove_file(&img);
1691        let _ = std::fs::remove_file(&dev);
1692    }
1693
1694    /// The pipeline must emit the Rereading stage on every platform.
1695    #[test]
1696    fn test_pipeline_emits_rereading_stage() {
1697        let dir = std::env::temp_dir();
1698        let img = dir.join("fk_reread_stage_img.bin");
1699        let dev = dir.join("fk_reread_stage_dev.bin");
1700
1701        let data: Vec<u8> = vec![0xABu8; 256 * 1024];
1702        std::fs::write(&img, &data).unwrap();
1703        std::fs::File::create(&dev).unwrap();
1704
1705        let (tx, rx) = make_channel();
1706        let cancel = Arc::new(AtomicBool::new(false));
1707        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
1708
1709        let events = drain(&rx);
1710        assert!(
1711            has_stage(&events, &FlashStage::Rereading),
1712            "Rereading stage must be emitted on every platform"
1713        );
1714
1715        let _ = std::fs::remove_file(&img);
1716        let _ = std::fs::remove_file(&dev);
1717    }
1718
1719    /// The pipeline must emit the Verifying stage on every platform.
1720    #[test]
1721    fn test_pipeline_emits_verifying_stage() {
1722        let dir = std::env::temp_dir();
1723        let img = dir.join("fk_verify_stage_img.bin");
1724        let dev = dir.join("fk_verify_stage_dev.bin");
1725
1726        let data: Vec<u8> = vec![0xCDu8; 256 * 1024];
1727        std::fs::write(&img, &data).unwrap();
1728        std::fs::File::create(&dev).unwrap();
1729
1730        let (tx, rx) = make_channel();
1731        let cancel = Arc::new(AtomicBool::new(false));
1732        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
1733
1734        let events = drain(&rx);
1735        assert!(
1736            has_stage(&events, &FlashStage::Verifying),
1737            "Verifying stage must be emitted on every platform"
1738        );
1739
1740        let _ = std::fs::remove_file(&img);
1741        let _ = std::fs::remove_file(&dev);
1742    }
1743
1744    // ── open_device_for_writing error messages ───────────────────────────────
1745
1746    /// Opening a path that does not exist must produce an error that mentions
1747    /// the device path — verified on all platforms.
1748    #[test]
1749    fn test_open_device_for_writing_nonexistent_mentions_path() {
1750        let bad = if cfg!(target_os = "windows") {
1751            r"\\.\PhysicalDrive999".to_string()
1752        } else {
1753            "/nonexistent/fk_bad_device".to_string()
1754        };
1755
1756        // open_device_for_writing is private; exercise it via write_image.
1757        let dir = std::env::temp_dir();
1758        let img = dir.join("fk_open_err_img.bin");
1759        std::fs::write(&img, vec![1u8; 512]).unwrap();
1760
1761        let (tx, _rx) = make_channel();
1762        let cancel = Arc::new(AtomicBool::new(false));
1763        let result = write_image(img.to_str().unwrap(), &bad, 512, &tx, &cancel);
1764
1765        assert!(result.is_err(), "must fail for nonexistent device");
1766        // The error string should mention the device path.
1767        assert!(
1768            result.as_ref().unwrap_err().contains("PhysicalDrive999")
1769                || result.as_ref().unwrap_err().contains("fk_bad_device")
1770                || result.as_ref().unwrap_err().contains("Cannot open"),
1771            "error should reference the bad path: {:?}",
1772            result
1773        );
1774
1775        let _ = std::fs::remove_file(&img);
1776    }
1777
1778    // ── sync_device emits a log message ─────────────────────────────────────
1779
1780    /// sync_device must emit at least one FlashEvent::Log containing the
1781    /// word "flushed" or "flush" on every platform.
1782    #[test]
1783    fn test_sync_device_emits_log() {
1784        let dir = std::env::temp_dir();
1785        let dev = dir.join("fk_sync_log_dev.bin");
1786        std::fs::File::create(&dev).unwrap();
1787
1788        let (tx, rx) = make_channel();
1789        sync_device(dev.to_str().unwrap(), &tx);
1790
1791        let events = drain(&rx);
1792        let has_flush_log = events.iter().any(|e| {
1793            if let FlashEvent::Log(msg) = e {
1794                let lower = msg.to_lowercase();
1795                lower.contains("flush") || lower.contains("cache")
1796            } else {
1797                false
1798            }
1799        });
1800        assert!(
1801            has_flush_log,
1802            "sync_device must emit a flush/cache log event"
1803        );
1804
1805        let _ = std::fs::remove_file(&dev);
1806    }
1807
1808    // ── reread_partition_table emits a log message ───────────────────────────
1809
1810    /// reread_partition_table must emit at least one FlashEvent::Log on every
1811    /// platform — either a success message or a warning.
1812    #[test]
1813    fn test_reread_partition_table_emits_log() {
1814        let dir = std::env::temp_dir();
1815        let dev = dir.join("fk_reread_log_dev.bin");
1816        std::fs::File::create(&dev).unwrap();
1817
1818        let (tx, rx) = make_channel();
1819        reread_partition_table(dev.to_str().unwrap(), &tx);
1820
1821        let events = drain(&rx);
1822        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
1823        assert!(
1824            has_log,
1825            "reread_partition_table must emit at least one Log event"
1826        );
1827
1828        let _ = std::fs::remove_file(&dev);
1829    }
1830
1831    // ── unmount_device emits a log message ───────────────────────────────────
1832
1833    /// unmount_device on a temp-file path (which is never mounted) must emit
1834    /// the "no mounted partitions" log without panicking on any platform.
1835    #[test]
1836    fn test_unmount_device_no_partitions_emits_log() {
1837        let dir = std::env::temp_dir();
1838        let dev = dir.join("fk_unmount_log_dev.bin");
1839        std::fs::File::create(&dev).unwrap();
1840
1841        let path_str = dev.to_str().unwrap();
1842        let (tx, rx) = make_channel();
1843        unmount_device(path_str, &tx);
1844
1845        let events = drain(&rx);
1846        // Must emit at least one Log event (either "no partitions" or a warning).
1847        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
1848        assert!(has_log, "unmount_device must emit at least one Log event");
1849
1850        let _ = std::fs::remove_file(&dev);
1851    }
1852
1853    // ── Pipeline all-stages ordering ─────────────────────────────────────────
1854
1855    /// The pipeline must emit stages in the documented order:
1856    /// Unmounting → Writing → Syncing → Rereading → Verifying.
1857    #[test]
1858    fn test_pipeline_stage_ordering() {
1859        let dir = std::env::temp_dir();
1860        let img = dir.join("fk_order_img.bin");
1861        let dev = dir.join("fk_order_dev.bin");
1862
1863        let data: Vec<u8> = (0u8..=255).cycle().take(256 * 1024).collect();
1864        std::fs::write(&img, &data).unwrap();
1865        std::fs::File::create(&dev).unwrap();
1866
1867        let (tx, rx) = make_channel();
1868        let cancel = Arc::new(AtomicBool::new(false));
1869        run_pipeline(img.to_str().unwrap(), dev.to_str().unwrap(), tx, cancel);
1870
1871        let events = drain(&rx);
1872
1873        // Collect all Stage events in order.
1874        let stages: Vec<&FlashStage> = events
1875            .iter()
1876            .filter_map(|e| {
1877                if let FlashEvent::Stage(s) = e {
1878                    Some(s)
1879                } else {
1880                    None
1881                }
1882            })
1883            .collect();
1884
1885        // Verify the mandatory stages appear and in correct relative order.
1886        let pos = |target: &FlashStage| {
1887            stages
1888                .iter()
1889                .position(|s| *s == target)
1890                .unwrap_or(usize::MAX)
1891        };
1892
1893        let unmounting = pos(&FlashStage::Unmounting);
1894        let writing = pos(&FlashStage::Writing);
1895        let syncing = pos(&FlashStage::Syncing);
1896        let rereading = pos(&FlashStage::Rereading);
1897        let verifying = pos(&FlashStage::Verifying);
1898
1899        assert!(unmounting < writing, "Unmounting must precede Writing");
1900        assert!(writing < syncing, "Writing must precede Syncing");
1901        assert!(syncing < rereading, "Syncing must precede Rereading");
1902        assert!(rereading < verifying, "Rereading must precede Verifying");
1903
1904        let _ = std::fs::remove_file(&img);
1905        let _ = std::fs::remove_file(&dev);
1906    }
1907
1908    // ── Linux-specific tests ─────────────────────────────────────────────────
1909
1910    /// On Linux, find_mounted_partitions reads /proc/mounts.
1911    /// Verify it returns a Vec without panicking (live test).
1912    #[test]
1913    #[cfg(target_os = "linux")]
1914    fn test_find_mounted_partitions_linux_no_panic() {
1915        // sda is unlikely to be mounted in CI, but the function must not panic.
1916        let result = find_mounted_partitions("sda", "/dev/sda");
1917        let _ = result;
1918    }
1919
1920    /// On Linux, /proc/mounts always contains at least one line (the root
1921    /// filesystem), so reading a clearly-mounted device (e.g. something at /)
1922    /// should find entries.
1923    #[test]
1924    #[cfg(target_os = "linux")]
1925    fn test_find_mounted_partitions_linux_reads_proc_mounts() {
1926        // We can't know exactly which device is at /, but we can verify
1927        // that the function can parse whatever /proc/mounts contains.
1928        let content = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
1929        // If /proc/mounts is non-empty there must be at least one entry parseable.
1930        if !content.is_empty() {
1931            // Parse first real /dev/ device from /proc/mounts and verify
1932            // find_mounted_partitions does not panic on it.
1933            if let Some(line) = content.lines().find(|l| l.starts_with("/dev/")) {
1934                if let Some(dev) = line.split_whitespace().next() {
1935                    let name = std::path::Path::new(dev)
1936                        .file_name()
1937                        .map(|n| n.to_string_lossy().to_string())
1938                        .unwrap_or_default();
1939                    let _ = find_mounted_partitions(&name, dev);
1940                }
1941            }
1942        }
1943    }
1944
1945    /// On Linux, do_unmount on a path that is not mounted must emit a
1946    /// warning log (umount2 will fail with EINVAL) but must not panic.
1947    #[test]
1948    #[cfg(target_os = "linux")]
1949    fn test_do_unmount_not_mounted_emits_warning() {
1950        let (tx, rx) = make_channel();
1951        do_unmount("/dev/fk_nonexistent_part", &tx);
1952        let events = drain(&rx);
1953        // Should emit a warning log — the unmount will fail because the
1954        // path doesn't exist, but the function must not panic.
1955        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
1956        assert!(has_log, "do_unmount must emit a Log event on failure");
1957    }
1958
1959    // ── macOS-specific tests ─────────────────────────────────────────────────
1960
1961    /// On macOS, do_unmount with a bogus partition path must emit a warning
1962    /// log (diskutil will fail) but must not panic.
1963    #[test]
1964    #[cfg(target_os = "macos")]
1965    fn test_do_unmount_macos_bad_path_emits_warning() {
1966        let (tx, rx) = make_channel();
1967        do_unmount("/dev/fk_nonexistent_part", &tx);
1968        let events = drain(&rx);
1969        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
1970        assert!(has_log, "do_unmount must emit a Log event on failure");
1971    }
1972
1973    /// On macOS, find_mounted_partitions reads /proc/mounts (which doesn't
1974    /// exist) or falls back gracefully — must not panic.
1975    #[test]
1976    #[cfg(target_os = "macos")]
1977    fn test_find_mounted_partitions_macos_no_panic() {
1978        let result = find_mounted_partitions("disk2", "/dev/disk2");
1979        let _ = result;
1980    }
1981
1982    /// On macOS, reread_partition_table calls diskutil — must emit a log even
1983    /// if the path is a temp file (diskutil will fail gracefully).
1984    #[test]
1985    #[cfg(target_os = "macos")]
1986    fn test_reread_partition_table_macos_emits_log() {
1987        let dir = std::env::temp_dir();
1988        let dev = dir.join("fk_macos_reread_dev.bin");
1989        std::fs::File::create(&dev).unwrap();
1990
1991        let (tx, rx) = make_channel();
1992        reread_partition_table(dev.to_str().unwrap(), &tx);
1993
1994        let events = drain(&rx);
1995        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
1996        assert!(has_log, "reread_partition_table must emit a log on macOS");
1997
1998        let _ = std::fs::remove_file(&dev);
1999    }
2000
2001    // ── Windows-specific pipeline tests ─────────────────────────────────────
2002
2003    /// On Windows, find_mounted_partitions delegates to
2004    /// windows::find_volumes_on_physical_drive — verify it does not panic
2005    /// for a well-formed but nonexistent drive.
2006    #[test]
2007    #[cfg(target_os = "windows")]
2008    fn test_find_mounted_partitions_windows_nonexistent() {
2009        let result = find_mounted_partitions("PhysicalDrive999", r"\\.\PhysicalDrive999");
2010        assert!(
2011            result.is_empty(),
2012            "nonexistent physical drive should have no volumes"
2013        );
2014    }
2015
2016    /// On Windows, do_unmount on a bad volume path must emit a warning log
2017    /// and not panic.
2018    #[test]
2019    #[cfg(target_os = "windows")]
2020    fn test_do_unmount_windows_bad_volume_emits_log() {
2021        let (tx, rx) = make_channel();
2022        do_unmount(r"\\.\Z99:", &tx);
2023        let events = drain(&rx);
2024        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2025        assert!(has_log, "do_unmount on bad volume must emit a Log event");
2026    }
2027
2028    /// On Windows, sync_device on a nonexistent physical drive path should
2029    /// emit a warning log (FlushFileBuffers will fail) but not panic.
2030    #[test]
2031    #[cfg(target_os = "windows")]
2032    fn test_sync_device_windows_bad_path_no_panic() {
2033        let (tx, rx) = make_channel();
2034        sync_device(r"\\.\PhysicalDrive999", &tx);
2035        let events = drain(&rx);
2036        // Must emit at least one log event (either flush warning or the
2037        // normal "caches flushed" message).
2038        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2039        assert!(has_log, "sync_device must emit a Log event on Windows");
2040    }
2041
2042    /// On Windows, reread_partition_table on a nonexistent drive must emit
2043    /// a warning log and not panic.
2044    #[test]
2045    #[cfg(target_os = "windows")]
2046    fn test_reread_partition_table_windows_bad_path_no_panic() {
2047        let (tx, rx) = make_channel();
2048        reread_partition_table(r"\\.\PhysicalDrive999", &tx);
2049        let events = drain(&rx);
2050        let has_log = events.iter().any(|e| matches!(e, FlashEvent::Log(_)));
2051        assert!(
2052            has_log,
2053            "reread_partition_table must emit a Log event on Windows"
2054        );
2055    }
2056
2057    /// On Windows, open_device_for_writing on a nonexistent physical drive
2058    /// must return an Err containing a meaningful message.
2059    #[test]
2060    #[cfg(target_os = "windows")]
2061    fn test_open_device_for_writing_windows_access_denied_message() {
2062        let dir = std::env::temp_dir();
2063        let img = dir.join("fk_win_open_img.bin");
2064        std::fs::write(&img, vec![1u8; 512]).unwrap();
2065
2066        let (tx, _rx) = make_channel();
2067        let cancel = Arc::new(AtomicBool::new(false));
2068        let result = write_image(
2069            img.to_str().unwrap(),
2070            r"\\.\PhysicalDrive999",
2071            512,
2072            &tx,
2073            &cancel,
2074        );
2075
2076        assert!(result.is_err());
2077        let msg = result.unwrap_err();
2078        // Must mention either the path, or give a clear error.
2079        assert!(
2080            msg.contains("PhysicalDrive999")
2081                || msg.contains("Access denied")
2082                || msg.contains("Cannot open"),
2083            "error must be descriptive: {msg}"
2084        );
2085
2086        let _ = std::fs::remove_file(&img);
2087    }
2088}