1#[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
43const BLOCK_SIZE: usize = 4 * 1024 * 1024;
49
50const PROGRESS_INTERVAL: Duration = Duration::from_millis(400);
52
53fn 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
67fn 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
75fn 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
92static REAL_UID: OnceLock<u32> = OnceLock::new();
101
102pub fn set_real_uid(uid: u32) {
107 let _ = REAL_UID.set(uid);
108}
109
110pub 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#[cfg(unix)]
147pub fn reexec_as_root() {
148 if is_running_under_test_harness() {
162 return;
163 }
164
165 #[cfg(test)]
168 return;
169
170 #[cfg(not(test))]
171 reexec_as_root_inner();
172}
173
174#[cfg(unix)]
180fn is_running_under_test_harness() -> bool {
181 if std::env::var("FLASHKRAFT_NO_REEXEC").is_ok() {
183 return true;
184 }
185
186 if std::env::var("NEXTEST_TEST_FILTER").is_ok() {
188 return true;
189 }
190
191 if let Ok(exe) = std::env::current_exe() {
199 let path_str = exe.to_string_lossy();
200 if path_str.contains("/deps/") {
202 return true;
203 }
204 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 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 std::env::set_var("FLASHKRAFT_ESCALATED", "1");
235
236 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 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")); 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 std::env::remove_var("FLASHKRAFT_ESCALATED");
261}
262
263#[cfg(not(unix))]
265pub fn reexec_as_root() {}
266
267#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum FlashStage {
306 Starting,
308 Unmounting,
310 Writing,
312 Syncing,
314 Rereading,
316 Verifying,
318 Done,
320 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 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
362pub 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#[derive(Debug, Clone)]
392pub enum FlashEvent {
393 Stage(FlashStage),
395 Progress {
397 bytes_written: u64,
398 total_bytes: u64,
399 speed_mb_s: f32,
400 },
401 VerifyProgress {
413 phase: &'static str,
414 bytes_read: u64,
415 total_bytes: u64,
416 speed_mb_s: f32,
417 },
418 Log(String),
420 Done,
422 Error(String),
424}
425
426#[derive(Debug, Clone)]
449pub enum FlashUpdate {
450 Progress {
454 progress: f32,
455 bytes_written: u64,
456 speed_mb_s: f32,
457 },
458 VerifyProgress {
464 phase: &'static str,
465 overall: f32,
466 bytes_read: u64,
467 total_bytes: u64,
468 speed_mb_s: f32,
469 },
470 Message(String),
472 Completed,
474 Failed(String),
476}
477
478impl From<FlashEvent> for FlashUpdate {
479 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
534pub 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
560fn send(tx: &mpsc::Sender<FlashEvent>, event: FlashEvent) {
565 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 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 #[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 send(tx, FlashEvent::Stage(FlashStage::Unmounting));
598 unmount_device(device_path, tx);
599
600 #[cfg(target_os = "linux")]
606 check_device_not_busy(device_path)?;
607
608 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 send(tx, FlashEvent::Stage(FlashStage::Syncing));
620 sync_device(device_path, tx);
621
622 send(tx, FlashEvent::Stage(FlashStage::Rereading));
624 reread_partition_table(device_path, tx);
625
626 send(tx, FlashEvent::Stage(FlashStage::Verifying));
628 verify(image_path, device_path, image_size, tx)?;
629
630 send(tx, FlashEvent::Done);
632 Ok(())
633}
634
635#[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#[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 }
674 Ok(())
675}
676
677#[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 stem[..stem.len() - 1].ends_with(|c: char| c.is_ascii_digit())
696 } else {
697 !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 let stem = dev_name.trim_end_matches(|c: char| c.is_ascii_digit());
718 &stem[..stem.len() - 1]
719 } else {
720 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
732fn open_device_for_writing(device_path: &str) -> Result<std::fs::File, String> {
739 #[cfg(unix)]
740 {
741 use nix::unistd::seteuid;
742
743 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 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 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 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
818fn 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
837fn 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 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 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 if which_exists("udisksctl") {
911 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 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 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 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 libc::EINVAL => {}
967 libc::ENOENT => {}
969 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 #[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
1020fn 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 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; }
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 writer
1083 .flush()
1084 .map_err(|e| format!("Buffer flush error: {e}"))?;
1085
1086 #[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 #[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 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
1124fn 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 #[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#[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 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#[cfg(target_os = "windows")]
1221fn reread_partition_table(device_path: &str, tx: &mpsc::Sender<FlashEvent>) {
1222 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
1247fn 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
1285fn 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#[cfg(target_os = "windows")]
1363mod windows {
1364 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 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 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 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(), 0,
1430 std::ptr::null_mut(), 0,
1432 &mut bytes_returned,
1433 std::ptr::null_mut(), )
1435 };
1436 if ok == FALSE {
1437 Err(format!("{}", std::io::Error::last_os_error()))
1438 } else {
1439 Ok(())
1440 }
1441 }
1442
1443 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 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 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 let drive_letters: Vec<String> = buf[..len as usize]
1479 .split(|&c| c == 0)
1480 .filter(|s| !s.is_empty())
1481 .map(|s| {
1482 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 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 let lock_result = device_ioctl(handle, FSCTL_LOCK_VOLUME);
1557 if let Err(ref e) = lock_result {
1558 eprintln!(
1560 "[flash] FSCTL_LOCK_VOLUME on '{volume_path}' failed ({e}); \
1561 attempting dismount anyway"
1562 );
1563 }
1564
1565 let dismount_result = device_ioctl(handle, FSCTL_DISMOUNT_VOLUME);
1567
1568 unsafe { CloseHandle(handle) };
1569
1570 lock_result.and(dismount_result)
1571 }
1572
1573 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 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 #[cfg(test)]
1598 mod tests {
1599 use super::*;
1600
1601 #[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 #[test]
1611 fn test_to_wide_empty() {
1612 let wide = to_wide("");
1613 assert_eq!(wide, vec![0u16]);
1614 }
1615
1616 #[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 #[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 #[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 #[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 #[test]
1647 fn test_find_volumes_bad_path_no_panic() {
1648 let result = find_volumes_on_physical_drive("not-a-valid-path");
1649 let _ = result;
1651 }
1652
1653 #[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#[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 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 #[test]
1713 fn test_is_privileged_returns_bool() {
1714 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 std::env::set_var("FLASHKRAFT_ESCALATED", "1");
1725 reexec_as_root(); std::env::remove_var("FLASHKRAFT_ESCALATED");
1727 }
1728
1729 #[test]
1730 fn test_set_real_uid_stores_value() {
1731 set_real_uid(1000);
1734 }
1735
1736 #[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 #[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 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 #[test]
1839 fn test_find_mounted_partitions_parses_proc_mounts_format() {
1840 let result = find_mounted_partitions("sda", "/dev/sda");
1843 let _ = result; }
1845
1846 #[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 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 #[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; {
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 let image_size: u64 = 8 * 1024 * 1024; {
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)); 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 #[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 #[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 #[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 let has_progress = events
2164 .iter()
2165 .any(|e| matches!(e, FlashEvent::Progress { .. }));
2166 assert!(has_progress, "must emit Progress events");
2167
2168 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 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 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 #[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 #[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 #[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(); }
2254 }
2255
2256 #[test]
2261 fn test_find_mounted_partitions_nonexistent_device_returns_empty() {
2262 #[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 let _ = result;
2270 }
2271
2272 #[test]
2275 fn test_find_mounted_partitions_empty_name_no_panic() {
2276 let result = find_mounted_partitions("", "");
2277 let _ = result;
2278 }
2279
2280 #[test]
2285 fn test_is_partition_of_windows_style_paths() {
2286 assert!(!is_partition_of(r"\\.\PhysicalDrive0", "PhysicalDrive0"));
2288 assert!(!is_partition_of(r"\\.\PhysicalDrive1", "PhysicalDrive0"));
2289 }
2290
2291 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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 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 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 #[test]
2517 #[cfg(target_os = "linux")]
2518 fn test_find_mounted_partitions_linux_no_panic() {
2519 let result = find_mounted_partitions("sda", "/dev/sda");
2521 let _ = result;
2522 }
2523
2524 #[test]
2528 #[cfg(target_os = "linux")]
2529 fn test_find_mounted_partitions_linux_reads_proc_mounts() {
2530 let content = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
2533 if !content.is_empty() {
2535 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 assert!((verify_overall_progress("other", 0.0) - 0.5).abs() < f32::EPSILON);
2766 }
2767
2768 #[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 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 #[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 #[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 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 #[test]
2927 #[cfg(target_os = "windows")]
2928 fn open_device_for_writing_sharing_violation_message() {
2929 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 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 #[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 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 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 #[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 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 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 }
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}